Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.81% covered (warning)
89.81%
564 / 628
56.41% covered (warning)
56.41%
22 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playlist_Manager
89.81% covered (warning)
89.81%
564 / 628
56.41% covered (warning)
56.41%
22 / 39
204.68
0.00% covered (danger)
0.00%
0 / 1
 register_rest_routes
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
1 / 1
1
 playlist_args
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
2
 edit_permissions_callback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 update_permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rest_list_playlists
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 rest_get_playlist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rest_create_playlist
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 rest_update_playlist
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 rest_delete_playlist
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 changes_from_request
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 query
89.36% covered (warning)
89.36%
42 / 47
0.00% covered (danger)
0.00%
0 / 1
11.15
 create
82.50% covered (warning)
82.50%
33 / 40
0.00% covered (danger)
0.00%
0 / 1
12.77
 update
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
15.15
 delete
70.73% covered (warning)
70.73%
29 / 41
0.00% covered (danger)
0.00%
0 / 1
9.60
 locate
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
8.69
 change_block_at
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 remove_block_at
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 is_whitespace_block
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 without_inner_slot
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 apply_changes
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
13
 sanitize_settings
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 settings_from_attrs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sync_heading
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 heading_text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 with_heading_text
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 find_block_path
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 block_at
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 block_key
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 playlist_block_paths
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 records
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 unpublished_posts
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 post_types
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 modified_time
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 find_record
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 record_for_block
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 block_attrs
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 prepare_item
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
14
 cannot_publish
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 not_found
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Playlist management for the VideoPress dashboard.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Error;
11use WP_Post;
12use WP_Query;
13use WP_REST_Request;
14use WP_REST_Response;
15use WP_REST_Server;
16
17/**
18 * Lists, creates, edits and deletes the site's playlists.
19 *
20 * A playlist is a Video Playlist block in a post, so every change rewrites
21 * that block in its post: Playlist_Index picks published changes up from the
22 * post save. Playlists in unpublished posts ( drafts, pending, scheduled,
23 * private ) aren't indexed; they are read straight from their posts so the
24 * dashboard can list the ones it creates before they go live.
25 */
26class Playlist_Manager {
27
28    /**
29     * Route base under Rest_Controller::REST_NAMESPACE.
30     *
31     * @var string
32     */
33    const ROUTE = '/playlists';
34
35    /**
36     * Accepted playlist keys, as sanitize_key() leaves them.
37     *
38     * @var string
39     */
40    const KEY_PATTERN = '[a-z0-9_\-]+';
41
42    /**
43     * Fields the list can be sorted by.
44     *
45     * @var string[]
46     */
47    const ORDERBY = array( 'updated', 'title', 'videos' );
48
49    /**
50     * Post statuses a listed playlist can have, `publish` first.
51     *
52     * @var string[]
53     */
54    const STATUSES = array( 'publish', 'future', 'draft', 'pending', 'private' );
55
56    /**
57     * Statuses a playlist can be switched to from the dashboard.
58     *
59     * @var string[]
60     */
61    const SETTABLE_STATUSES = array( 'publish', 'draft' );
62
63    /**
64     * Post types a new playlist can be created in.
65     *
66     * @var string[]
67     */
68    const CREATABLE_POST_TYPES = array( 'page', 'post' );
69
70    /**
71     * Most unpublished posts read per request.
72     *
73     * @var int
74     */
75    const UNPUBLISHED_LIMIT = 200;
76
77    /**
78     * The block's display and playback attributes the dashboard edits, as
79     * block.json declares them ( Playlist_Manager_Test keeps the two in step ).
80     *
81     * @var array[]
82     */
83    const SETTINGS = array(
84        'showPlaylistTitle'    => array(
85            'type'    => 'boolean',
86            'default' => true,
87        ),
88        'layout'               => array(
89            'type'    => 'string',
90            'enum'    => array( 'side-rail', 'grid', 'strip' ),
91            'default' => 'side-rail',
92        ),
93        'darkPlayer'           => array(
94            'type'    => 'boolean',
95            'default' => false,
96        ),
97        'showPlayer'           => array(
98            'type'    => 'boolean',
99            'default' => true,
100        ),
101        'entryClickAction'     => array(
102            'type'    => 'string',
103            'enum'    => array( 'new-tab', 'show-player' ),
104            'default' => 'new-tab',
105        ),
106        'autoplayNext'         => array(
107            'type'    => 'boolean',
108            'default' => false,
109        ),
110        'muteByDefault'        => array(
111            'type'    => 'boolean',
112            'default' => false,
113        ),
114        'loopPlaylist'         => array(
115            'type'    => 'boolean',
116            'default' => false,
117        ),
118        'showThumbnail'        => array(
119            'type'    => 'boolean',
120            'default' => true,
121        ),
122        'showTitle'            => array(
123            'type'    => 'boolean',
124            'default' => true,
125        ),
126        'showResolution'       => array(
127            'type'    => 'boolean',
128            'default' => true,
129        ),
130        'showDuration'         => array(
131            'type'    => 'boolean',
132            'default' => true,
133        ),
134        'showPositionNumber'   => array(
135            'type'    => 'boolean',
136            'default' => false,
137        ),
138        'showTotalRuntime'     => array(
139            'type'    => 'boolean',
140            'default' => true,
141        ),
142        'entryTitleFontFamily' => array(
143            'type'    => 'string',
144            'default' => '',
145        ),
146    );
147
148    /**
149     * Register the playlist routes.
150     *
151     * @return void
152     */
153    public static function register_rest_routes() {
154        register_rest_route(
155            Rest_Controller::REST_NAMESPACE,
156            self::ROUTE,
157            array(
158                array(
159                    'methods'             => WP_REST_Server::READABLE,
160                    'callback'            => array( __CLASS__, 'rest_list_playlists' ),
161                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
162                    'args'                => array(
163                        'page'     => array(
164                            'type'    => 'integer',
165                            'default' => 1,
166                            'minimum' => 1,
167                        ),
168                        'per_page' => array(
169                            'type'    => 'integer',
170                            'default' => 12,
171                            'minimum' => 1,
172                            'maximum' => 100,
173                        ),
174                        'search'   => array(
175                            'type'    => 'string',
176                            'default' => '',
177                        ),
178                        'orderby'  => array(
179                            'type'    => 'string',
180                            'default' => 'updated',
181                            'enum'    => self::ORDERBY,
182                        ),
183                        'order'    => array(
184                            'type'    => 'string',
185                            'default' => 'desc',
186                            'enum'    => array( 'asc', 'desc' ),
187                        ),
188                        'status'   => array(
189                            'type'    => 'array',
190                            'items'   => array(
191                                'type' => 'string',
192                                'enum' => self::STATUSES,
193                            ),
194                            'default' => array(),
195                        ),
196                    ),
197                ),
198                array(
199                    'methods'             => WP_REST_Server::CREATABLE,
200                    'callback'            => array( __CLASS__, 'rest_create_playlist' ),
201                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
202                    'args'                => array_merge(
203                        self::playlist_args(),
204                        array(
205                            'postType' => array(
206                                'type'    => 'string',
207                                'enum'    => self::CREATABLE_POST_TYPES,
208                                'default' => 'page',
209                            ),
210                            'status'   => array(
211                                'type'    => 'string',
212                                'enum'    => self::SETTABLE_STATUSES,
213                                'default' => 'draft',
214                            ),
215                        )
216                    ),
217                ),
218            )
219        );
220
221        register_rest_route(
222            Rest_Controller::REST_NAMESPACE,
223            self::ROUTE . '/(?P<key>' . self::KEY_PATTERN . ')',
224            array(
225                array(
226                    'methods'             => WP_REST_Server::READABLE,
227                    'callback'            => array( __CLASS__, 'rest_get_playlist' ),
228                    'permission_callback' => array( Rest_Controller::class, 'permissions_callback' ),
229                ),
230                array(
231                    'methods'             => WP_REST_Server::EDITABLE,
232                    'callback'            => array( __CLASS__, 'rest_update_playlist' ),
233                    'permission_callback' => array( __CLASS__, 'edit_permissions_callback' ),
234                    'args'                => array_merge(
235                        self::playlist_args(),
236                        array(
237                            'status' => array(
238                                'type' => 'string',
239                                'enum' => self::SETTABLE_STATUSES,
240                            ),
241                        )
242                    ),
243                ),
244                array(
245                    'methods'             => WP_REST_Server::DELETABLE,
246                    'callback'            => array( __CLASS__, 'rest_delete_playlist' ),
247                    'permission_callback' => array( __CLASS__, 'edit_permissions_callback' ),
248                ),
249            )
250        );
251    }
252
253    /**
254     * Arguments shared by the create and update routes.
255     *
256     * @return array
257     */
258    private static function playlist_args() {
259        $settings = array();
260        foreach ( self::SETTINGS as $name => $schema ) {
261            unset( $schema['default'] );
262            $settings[ $name ] = $schema;
263        }
264
265        return array(
266            'title'       => array(
267                'type'              => 'string',
268                'sanitize_callback' => 'sanitize_text_field',
269            ),
270            'description' => array(
271                'type'              => 'string',
272                'sanitize_callback' => 'sanitize_textarea_field',
273            ),
274            'videos'      => array(
275                'type'  => 'array',
276                'items' => array(
277                    'type'       => 'object',
278                    'properties' => array(
279                        'guid'       => array(
280                            'type'     => 'string',
281                            'pattern'  => '^[a-zA-Z0-9]{8}$',
282                            'required' => true,
283                        ),
284                        'durationMs' => array(
285                            'type'    => 'integer',
286                            'minimum' => 0,
287                        ),
288                        'height'     => array(
289                            'type'    => 'integer',
290                            'minimum' => 0,
291                        ),
292                    ),
293                ),
294            ),
295            'settings'    => array(
296                'type'                 => 'object',
297                'properties'           => $settings,
298                'additionalProperties' => false,
299            ),
300        );
301    }
302
303    /**
304     * Changing a playlist needs the dashboard capability and the right to edit its post.
305     *
306     * @param WP_REST_Request $request Incoming request.
307     * @return bool
308     */
309    public static function edit_permissions_callback( WP_REST_Request $request ) {
310        if ( ! Rest_Controller::permissions_callback() ) {
311            return false;
312        }
313
314        $record = self::find_record( (string) $request['key'] );
315
316        // Unknown keys fall through to the callback, which answers 404.
317        return null === $record || current_user_can( 'edit_post', $record['post_id'] );
318    }
319
320    /**
321     * Kept for callers of the first version of the routes.
322     *
323     * @param WP_REST_Request $request Incoming request.
324     * @return bool
325     */
326    public static function update_permissions_callback( WP_REST_Request $request ) {
327        return self::edit_permissions_callback( $request );
328    }
329
330    /**
331     * List the site's playlists, a page at a time.
332     *
333     * @param WP_REST_Request $request Incoming request.
334     * @return WP_REST_Response
335     */
336    public static function rest_list_playlists( WP_REST_Request $request ) {
337        $result = self::query(
338            array(
339                'page'     => (int) $request['page'],
340                'per_page' => (int) $request['per_page'],
341                'search'   => (string) $request['search'],
342                'orderby'  => (string) $request['orderby'],
343                'order'    => (string) $request['order'],
344                'status'   => (array) $request['status'],
345            )
346        );
347
348        $response = rest_ensure_response( array_map( array( __CLASS__, 'prepare_item' ), $result['items'] ) );
349        $response->header( 'X-WP-Total', (string) $result['total'] );
350        $response->header( 'X-WP-TotalPages', (string) $result['total_pages'] );
351
352        return $response;
353    }
354
355    /**
356     * Get one playlist.
357     *
358     * @param WP_REST_Request $request Incoming request.
359     * @return WP_REST_Response|WP_Error
360     */
361    public static function rest_get_playlist( WP_REST_Request $request ) {
362        $record = self::find_record( (string) $request['key'] );
363        if ( ! $record ) {
364            return self::not_found();
365        }
366
367        return rest_ensure_response( self::prepare_item( $record ) );
368    }
369
370    /**
371     * Create a playlist in a new post.
372     *
373     * @param WP_REST_Request $request Incoming request.
374     * @return WP_REST_Response|WP_Error
375     */
376    public static function rest_create_playlist( WP_REST_Request $request ) {
377        $record = self::create(
378            array_merge(
379                self::changes_from_request( $request ),
380                array(
381                    'post_type' => (string) $request['postType'],
382                    'status'    => (string) $request['status'],
383                )
384            )
385        );
386        if ( is_wp_error( $record ) ) {
387            return $record;
388        }
389
390        $response = rest_ensure_response( self::prepare_item( $record ) );
391        $response->set_status( 201 );
392
393        return $response;
394    }
395
396    /**
397     * Update a playlist's details, videos, settings or post status.
398     *
399     * @param WP_REST_Request $request Incoming request.
400     * @return WP_REST_Response|WP_Error
401     */
402    public static function rest_update_playlist( WP_REST_Request $request ) {
403        $changes = self::changes_from_request( $request );
404        if ( $request->has_param( 'status' ) ) {
405            $changes['status'] = (string) $request->get_param( 'status' );
406        }
407
408        $record = self::update( (string) $request['key'], $changes );
409        if ( is_wp_error( $record ) ) {
410            return $record;
411        }
412
413        return rest_ensure_response( self::prepare_item( $record ) );
414    }
415
416    /**
417     * Delete a playlist.
418     *
419     * @param WP_REST_Request $request Incoming request.
420     * @return WP_REST_Response|WP_Error
421     */
422    public static function rest_delete_playlist( WP_REST_Request $request ) {
423        $result = self::delete( (string) $request['key'] );
424        if ( is_wp_error( $result ) ) {
425            return $result;
426        }
427
428        return rest_ensure_response( $result );
429    }
430
431    /**
432     * The playlist changes a request carries.
433     *
434     * @param WP_REST_Request $request Incoming request.
435     * @return array Any of `title`, `description`, `videos` and `settings`.
436     */
437    private static function changes_from_request( WP_REST_Request $request ) {
438        $changes = array();
439        foreach ( array( 'title', 'description', 'videos', 'settings' ) as $field ) {
440            if ( $request->has_param( $field ) ) {
441                $changes[ $field ] = $request->get_param( $field );
442            }
443        }
444
445        return $changes;
446    }
447
448    /**
449     * Filter, sort and paginate the playlists.
450     *
451     * @param array $args {
452     *     Query arguments.
453     *
454     *     @type int      $page     1-based page.
455     *     @type int      $per_page Playlists per page.
456     *     @type string   $search   Text matched against titles and descriptions.
457     *     @type string   $orderby  One of ORDERBY.
458     *     @type string   $order    `asc` or `desc`.
459     *     @type string[] $status   Post statuses to keep; empty keeps all.
460     * }
461     * @return array{items: array[], total: int, total_pages: int}
462     */
463    public static function query( array $args ) {
464        $page     = max( 1, (int) ( $args['page'] ?? 1 ) );
465        $per_page = max( 1, (int) ( $args['per_page'] ?? 12 ) );
466        $search   = trim( (string) ( $args['search'] ?? '' ) );
467        $orderby  = in_array( $args['orderby'] ?? '', self::ORDERBY, true ) ? $args['orderby'] : 'updated';
468        $order    = 'asc' === ( $args['order'] ?? 'desc' ) ? 'asc' : 'desc';
469        $statuses = array_values( array_intersect( (array) ( $args['status'] ?? array() ), self::STATUSES ) );
470
471        $playlists = array_values( self::records() );
472
473        if ( $statuses ) {
474            $playlists = array_values(
475                array_filter(
476                    $playlists,
477                    function ( $playlist ) use ( $statuses ) {
478                        return in_array( $playlist['status'], $statuses, true );
479                    }
480                )
481            );
482        }
483
484        if ( '' !== $search ) {
485            $playlists = array_values(
486                array_filter(
487                    $playlists,
488                    function ( $playlist ) use ( $search ) {
489                        return false !== stripos( $playlist['title'], $search )
490                            || false !== stripos( $playlist['description'], $search );
491                    }
492                )
493            );
494        }
495
496        usort(
497            $playlists,
498            function ( $a, $b ) use ( $orderby, $order ) {
499                switch ( $orderby ) {
500                    case 'title':
501                        $cmp = strcasecmp( $a['title'], $b['title'] );
502                        break;
503                    case 'videos':
504                        $cmp = count( $a['videos'] ) - count( $b['videos'] );
505                        break;
506                    default:
507                        $cmp = $a['updated'] - $b['updated'];
508                }
509                if ( 0 === $cmp ) {
510                    return strcmp( $a['key'], $b['key'] );
511                }
512                return 'asc' === $order ? $cmp : -$cmp;
513            }
514        );
515
516        $total = count( $playlists );
517
518        return array(
519            'items'       => array_slice( $playlists, ( $page - 1 ) * $per_page, $per_page ),
520            'total'       => $total,
521            'total_pages' => (int) ceil( $total / $per_page ),
522        );
523    }
524
525    /**
526     * Create a playlist: a new post holding one Video Playlist block.
527     *
528     * @param array $args Any of `title`, `description`, `videos` and `settings`,
529     *                    plus the new post's `post_type` and `status`.
530     * @return array|WP_Error The new record.
531     */
532    public static function create( array $args ) {
533        $post_type = in_array( $args['post_type'] ?? '', self::CREATABLE_POST_TYPES, true ) ? $args['post_type'] : 'page';
534        $status    = in_array( $args['status'] ?? '', self::SETTABLE_STATUSES, true ) ? $args['status'] : 'draft';
535        $type      = get_post_type_object( $post_type );
536
537        if ( ! $type || ! current_user_can( $type->cap->create_posts ) ) {
538            return new WP_Error(
539                'videopress_playlist_cannot_create',
540                __( 'You aren’t allowed to create playlists.', 'jetpack-videopress-pkg' ),
541                array( 'status' => rest_authorization_required_code() )
542            );
543        }
544        if ( 'publish' === $status && ! current_user_can( $type->cap->publish_posts ) ) {
545            return self::cannot_publish();
546        }
547
548        $title = isset( $args['title'] ) && is_string( $args['title'] ) ? $args['title'] : '';
549        $block = self::apply_changes(
550            array(
551                'blockName'    => Playlist_Index::BLOCK_NAME,
552                'attrs'        => array( 'playlistId' => wp_generate_uuid4() ),
553                'innerBlocks'  => array(),
554                'innerHTML'    => '',
555                'innerContent' => array(),
556            ),
557            array_merge(
558                array( 'title' => $title ),
559                array_intersect_key( $args, array_flip( array( 'description', 'videos', 'settings' ) ) )
560            )
561        );
562
563        $post_id = wp_insert_post(
564            wp_slash(
565                array(
566                    'post_type'    => $post_type,
567                    'post_status'  => $status,
568                    'post_title'   => '' !== $title ? $title : __( 'Untitled playlist', 'jetpack-videopress-pkg' ),
569                    'post_content' => serialize_block( $block ),
570                )
571            ),
572            true
573        );
574        if ( is_wp_error( $post_id ) ) {
575            return $post_id;
576        }
577
578        $record = self::record_for_block( $post_id, array( 0 ) );
579
580        return $record ? $record : self::not_found();
581    }
582
583    /**
584     * Rewrite a playlist's block in its post, and optionally its post's status.
585     *
586     * @param string $key     Playlist key.
587     * @param array  $changes Any of `title`, `description`, `videos`, `settings` and `status`.
588     * @return array|WP_Error The updated record.
589     */
590    public static function update( $key, array $changes ) {
591        $target = self::locate( $key );
592        if ( is_wp_error( $target ) ) {
593            return $target;
594        }
595        list( $post, $blocks, $path ) = $target;
596
597        $postarr = array(
598            'ID'           => $post->ID,
599            'post_content' => serialize_blocks( self::change_block_at( $blocks, $path, $changes ) ),
600        );
601
602        // A post named after its playlist, as the ones the dashboard creates are, follows a rename.
603        $old_title = self::block_at( $blocks, $path )['attrs']['playlistTitle'] ?? '';
604        if ( isset( $changes['title'] ) && is_string( $changes['title'] ) && '' !== trim( $changes['title'] )
605            && $changes['title'] !== $old_title && $post->post_title === $old_title ) {
606            $postarr['post_title'] = $changes['title'];
607        }
608
609        $status = $changes['status'] ?? null;
610        if ( is_string( $status ) && in_array( $status, self::SETTABLE_STATUSES, true ) && $status !== $post->post_status ) {
611            $type = get_post_type_object( $post->post_type );
612            if ( 'publish' === $status && ( ! $type || ! current_user_can( $type->cap->publish_posts ) ) ) {
613                return self::cannot_publish();
614            }
615            $postarr['post_status'] = $status;
616        }
617
618        $updated = wp_update_post( wp_slash( $postarr ), true );
619        if ( is_wp_error( $updated ) ) {
620            return $updated;
621        }
622
623        $saved = self::record_for_block( $post->ID, $path );
624
625        return $saved ? $saved : self::not_found();
626    }
627
628    /**
629     * Delete a playlist: remove its block, and trash the post when nothing else is left in it.
630     *
631     * @param string $key Playlist key.
632     * @return array|WP_Error `deleted`, whether the post went to the trash, and the playlist as it was.
633     */
634    public static function delete( $key ) {
635        $record = self::find_record( $key );
636        $target = self::locate( $key );
637        if ( ! $record || is_wp_error( $target ) ) {
638            return $record ? $target : self::not_found();
639        }
640        list( $post, $blocks, $path ) = $target;
641
642        $previous = self::prepare_item( $record );
643        $content  = serialize_blocks( self::remove_block_at( $blocks, $path ) );
644
645        if ( '' === trim( $content ) ) {
646            if ( ! current_user_can( 'delete_post', $post->ID ) ) {
647                return new WP_Error(
648                    'videopress_playlist_cannot_delete',
649                    __( 'You aren’t allowed to delete the post this playlist is in.', 'jetpack-videopress-pkg' ),
650                    array( 'status' => rest_authorization_required_code() )
651                );
652            }
653            if ( ! wp_trash_post( $post->ID ) ) {
654                return new WP_Error(
655                    'videopress_playlist_delete_failed',
656                    __( 'The playlist could not be deleted.', 'jetpack-videopress-pkg' ),
657                    array( 'status' => 500 )
658                );
659            }
660
661            return array(
662                'deleted'     => true,
663                'postTrashed' => true,
664                'previous'    => $previous,
665            );
666        }
667
668        $updated = wp_update_post(
669            wp_slash(
670                array(
671                    'ID'           => $post->ID,
672                    'post_content' => $content,
673                )
674            ),
675            true
676        );
677        if ( is_wp_error( $updated ) ) {
678            return $updated;
679        }
680
681        return array(
682            'deleted'     => true,
683            'postTrashed' => false,
684            'previous'    => $previous,
685        );
686    }
687
688    /**
689     * Find a playlist's post, its parsed blocks and the path to its block.
690     *
691     * @param string $key Playlist key.
692     * @return array|WP_Error [ WP_Post, blocks, path ].
693     */
694    private static function locate( $key ) {
695        $record = self::find_record( $key );
696        if ( ! $record ) {
697            return self::not_found();
698        }
699
700        $post = get_post( $record['post_id'] );
701        if ( ! $post instanceof WP_Post ) {
702            return self::not_found();
703        }
704
705        if ( ! function_exists( 'wp_check_post_lock' ) ) {
706            require_once ABSPATH . 'wp-admin/includes/post.php';
707        }
708        if ( wp_check_post_lock( $post->ID ) ) {
709            return new WP_Error(
710                'videopress_playlist_post_locked',
711                __( 'Someone is editing the post this playlist is in. Try again once they are done.', 'jetpack-videopress-pkg' ),
712                array( 'status' => 409 )
713            );
714        }
715
716        $blocks = parse_blocks( $post->post_content );
717        $path   = self::find_block_path( $blocks, $record['key'], $post->ID );
718        if ( null === $path ) {
719            return self::not_found();
720        }
721
722        return array( $post, $blocks, $path );
723    }
724
725    /**
726     * Apply playlist changes to the block a path leads to.
727     *
728     * @param array $blocks  Parsed blocks.
729     * @param int[] $path    Indexes leading to the block through `innerBlocks`.
730     * @param array $changes Any of `title`, `description`, `videos` and `settings`.
731     * @return array The blocks, with that one changed.
732     */
733    private static function change_block_at( array $blocks, array $path, array $changes ) {
734        $index = array_shift( $path );
735
736        if ( $path ) {
737            $blocks[ $index ]['innerBlocks'] = self::change_block_at( $blocks[ $index ]['innerBlocks'], $path, $changes );
738        } else {
739            $blocks[ $index ] = self::apply_changes( $blocks[ $index ], $changes );
740        }
741
742        return $blocks;
743    }
744
745    /**
746     * Remove the block a path leads to, with its slot in the parent's inner content.
747     *
748     * @param array $blocks Parsed blocks.
749     * @param int[] $path   Indexes leading to the block through `innerBlocks`.
750     * @return array The blocks without that one.
751     */
752    private static function remove_block_at( array $blocks, array $path ) {
753        $index = array_shift( $path );
754
755        if ( $path ) {
756            $parent      = $blocks[ $index ];
757            $child_index = $path[0];
758            if ( 1 === count( $path ) ) {
759                $parent['innerContent'] = self::without_inner_slot( $parent['innerContent'] ?? array(), $child_index );
760            }
761            $parent['innerBlocks'] = self::remove_block_at( $parent['innerBlocks'], $path );
762            $blocks[ $index ]      = $parent;
763
764            return $blocks;
765        }
766
767        array_splice( $blocks, $index, 1 );
768
769        // Drop the whitespace that separated the block from its neighbour, so no blank gap is left.
770        foreach ( array( $index - 1, $index ) as $neighbour ) {
771            if ( isset( $blocks[ $neighbour ] ) && self::is_whitespace_block( $blocks[ $neighbour ] ) ) {
772                array_splice( $blocks, $neighbour, 1 );
773                break;
774            }
775        }
776
777        return array_values( $blocks );
778    }
779
780    /**
781     * Whether a parsed block is the whitespace parse_blocks() keeps between blocks.
782     *
783     * @param array $block Parsed block.
784     * @return bool
785     */
786    private static function is_whitespace_block( array $block ) {
787        return null === ( $block['blockName'] ?? null ) && '' === trim( (string) ( $block['innerHTML'] ?? '' ) );
788    }
789
790    /**
791     * Drop the n-th inner block placeholder from a block's inner content.
792     *
793     * @param array $inner_content Inner content: strings and null placeholders.
794     * @param int   $n             Index of the inner block.
795     * @return array
796     */
797    private static function without_inner_slot( array $inner_content, $n ) {
798        $seen = -1;
799        foreach ( $inner_content as $i => $chunk ) {
800            if ( null === $chunk && ++$seen === $n ) {
801                array_splice( $inner_content, $i, 1 );
802                break;
803            }
804        }
805
806        return $inner_content;
807    }
808
809    /**
810     * Apply playlist changes to a parsed Video Playlist block.
811     *
812     * The heading inner block mirrors the title, so it is kept in step with
813     * `playlistTitle` and `showPlaylistTitle` the way the block editor does.
814     *
815     * @param array $block   Parsed block.
816     * @param array $changes Any of `title`, `description`, `videos` and `settings`.
817     * @return array The block with the changes applied.
818     */
819    public static function apply_changes( array $block, array $changes ) {
820        $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
821
822        if ( isset( $changes['title'] ) && is_string( $changes['title'] ) ) {
823            $attrs['playlistTitle'] = $changes['title'];
824        }
825
826        if ( isset( $changes['description'] ) && is_string( $changes['description'] ) ) {
827            $attrs['playlistDescription'] = $changes['description'];
828        }
829
830        if ( isset( $changes['videos'] ) && is_array( $changes['videos'] ) ) {
831            // Stored the way the block editor stores them: unknown metadata is left out.
832            $attrs['videos'] = array_map(
833                function ( $entry ) {
834                    return array_filter(
835                        array(
836                            'guid'       => $entry['guid'],
837                            'durationMs' => $entry['durationMs'],
838                            'height'     => $entry['height'],
839                        )
840                    );
841                },
842                Initializer::sanitize_playlist_entries( $changes['videos'] )
843            );
844        }
845
846        if ( isset( $changes['settings'] ) && is_array( $changes['settings'] ) ) {
847            foreach ( self::sanitize_settings( $changes['settings'] ) as $name => $value ) {
848                // The block editor leaves defaults out of the markup; so does this.
849                if ( self::SETTINGS[ $name ]['default'] === $value ) {
850                    unset( $attrs[ $name ] );
851                } else {
852                    $attrs[ $name ] = $value;
853                }
854            }
855        }
856
857        $block['attrs'] = $attrs;
858
859        return self::sync_heading( $block );
860    }
861
862    /**
863     * Keep only known settings with valid values.
864     *
865     * @param array $settings Raw settings.
866     * @return array
867     */
868    public static function sanitize_settings( array $settings ) {
869        $clean = array();
870
871        foreach ( self::SETTINGS as $name => $schema ) {
872            if ( ! array_key_exists( $name, $settings ) ) {
873                continue;
874            }
875            $value = $settings[ $name ];
876
877            if ( 'boolean' === $schema['type'] ) {
878                if ( is_bool( $value ) ) {
879                    $clean[ $name ] = $value;
880                }
881                continue;
882            }
883
884            if ( ! is_string( $value ) ) {
885                continue;
886            }
887            if ( isset( $schema['enum'] ) ) {
888                if ( in_array( $value, $schema['enum'], true ) ) {
889                    $clean[ $name ] = $value;
890                }
891                continue;
892            }
893            // The only free-form setting is a font-family preset slug.
894            $clean[ $name ] = sanitize_key( $value );
895        }
896
897        return $clean;
898    }
899
900    /**
901     * A playlist block's settings, defaults filled in.
902     *
903     * @param array $attrs Block attributes.
904     * @return array
905     */
906    public static function settings_from_attrs( array $attrs ) {
907        $settings = array();
908        foreach ( self::SETTINGS as $name => $schema ) {
909            $settings[ $name ] = $schema['default'];
910        }
911
912        return array_merge( $settings, self::sanitize_settings( $attrs ) );
913    }
914
915    /**
916     * Keep the heading inner block in step with the title, as the block editor's template does:
917     * a level-2 heading holding the title while `showPlaylistTitle` is on, none otherwise.
918     *
919     * @param array $block Parsed Video Playlist block.
920     * @return array
921     */
922    private static function sync_heading( array $block ) {
923        $attrs    = $block['attrs'];
924        $show     = ! isset( $attrs['showPlaylistTitle'] ) || false !== $attrs['showPlaylistTitle'];
925        $title    = isset( $attrs['playlistTitle'] ) && is_string( $attrs['playlistTitle'] ) ? $attrs['playlistTitle'] : '';
926        $existing = $block['innerBlocks'][0] ?? null;
927
928        if ( ! $show ) {
929            $block['innerBlocks']  = array();
930            $block['innerHTML']    = '';
931            $block['innerContent'] = array();
932
933            return $block;
934        }
935
936        if ( is_array( $existing ) && 'core/heading' === ( $existing['blockName'] ?? null ) ) {
937            if ( self::heading_text( $existing ) !== $title ) {
938                $block['innerBlocks'][0] = self::with_heading_text( $existing, $title );
939            }
940
941            return $block;
942        }
943
944        $html                  = '<h2 class="wp-block-heading">' . esc_html( $title ) . '</h2>';
945        $block['innerBlocks']  = array(
946            array(
947                'blockName'    => 'core/heading',
948                'attrs'        => array(),
949                'innerBlocks'  => array(),
950                'innerHTML'    => $html,
951                'innerContent' => array( $html ),
952            ),
953        );
954        $block['innerHTML']    = "\n\n";
955        $block['innerContent'] = array( "\n", null, "\n" );
956
957        return $block;
958    }
959
960    /**
961     * The plain text of a parsed core/heading block.
962     *
963     * @param array $heading Parsed heading block.
964     * @return string
965     */
966    private static function heading_text( array $heading ) {
967        return html_entity_decode( wp_strip_all_tags( (string) ( $heading['innerHTML'] ?? '' ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
968    }
969
970    /**
971     * Replace the text of a parsed core/heading block.
972     *
973     * @param array  $heading Parsed heading block.
974     * @param string $text    New plain-text heading.
975     * @return array
976     */
977    private static function with_heading_text( array $heading, $text ) {
978        $replace = function ( $html ) use ( $text ) {
979            return is_string( $html )
980                ? preg_replace_callback(
981                    '/^(\s*<h[1-6]\b[^>]*>).*(<\/h[1-6]>\s*)$/s',
982                    function ( $matches ) use ( $text ) {
983                        return $matches[1] . esc_html( $text ) . $matches[2];
984                    },
985                    $html
986                )
987                : $html;
988        };
989
990        $heading['innerHTML']    = $replace( $heading['innerHTML'] ?? '' );
991        $heading['innerContent'] = array_map( $replace, $heading['innerContent'] ?? array() );
992
993        return $heading;
994    }
995
996    /**
997     * Locate the Video Playlist block a key names, mirroring how Playlist_Index keys them.
998     *
999     * @param array  $blocks  Parsed blocks of the post.
1000     * @param string $key     Playlist key.
1001     * @param int    $post_id Post the blocks belong to.
1002     * @return int[]|null Indexes leading to the block through `innerBlocks`, or null.
1003     */
1004    public static function find_block_path( array $blocks, $key, $post_id ) {
1005        $found = null;
1006
1007        foreach ( self::playlist_block_paths( $blocks ) as $ordinal => $path ) {
1008            $own_key = self::block_key( self::block_at( $blocks, $path ), $post_id, $ordinal );
1009
1010            // The index suffixes a key another post already owns with this post's id.
1011            // When a key repeats within the post, the index keeps the last block.
1012            if ( $own_key === $key || $own_key . '-' . $post_id === $key ) {
1013                $found = $path;
1014            }
1015        }
1016
1017        return $found;
1018    }
1019
1020    /**
1021     * The block a path leads to.
1022     *
1023     * @param array $blocks Parsed blocks.
1024     * @param int[] $path   Indexes leading to the block through `innerBlocks`.
1025     * @return array
1026     */
1027    private static function block_at( array $blocks, array $path ) {
1028        $block = $blocks[ $path[0] ];
1029        foreach ( array_slice( $path, 1 ) as $index ) {
1030            $block = $block['innerBlocks'][ $index ];
1031        }
1032
1033        return $block;
1034    }
1035
1036    /**
1037     * A playlist block's key before collisions with other posts are resolved.
1038     *
1039     * @param array $block   Parsed block.
1040     * @param int   $post_id Post the block belongs to.
1041     * @param int   $ordinal 0-based position among the post's playlist blocks.
1042     * @return string
1043     */
1044    private static function block_key( array $block, $post_id, $ordinal ) {
1045        $key = isset( $block['attrs']['playlistId'] ) && is_string( $block['attrs']['playlistId'] )
1046            ? sanitize_key( $block['attrs']['playlistId'] )
1047            : '';
1048
1049        return '' !== $key ? $key : $post_id . '-' . ( $ordinal + 1 );
1050    }
1051
1052    /**
1053     * Paths to every standalone Video Playlist block, in document order.
1054     *
1055     * @param array $blocks Parsed blocks.
1056     * @param int[] $prefix Path of the parent block.
1057     * @return int[][]
1058     */
1059    private static function playlist_block_paths( array $blocks, array $prefix = array() ) {
1060        $paths = array();
1061
1062        foreach ( $blocks as $index => $block ) {
1063            $name = $block['blockName'] ?? null;
1064            if ( in_array( $name, Playlist_Index::DYNAMIC_PLAYLIST_BLOCKS, true ) ) {
1065                continue;
1066            }
1067
1068            $path = array_merge( $prefix, array( $index ) );
1069            if ( Playlist_Index::BLOCK_NAME === $name ) {
1070                $source = $block['attrs']['source'] ?? 'own';
1071                if ( 'own' === $source ) {
1072                    $paths[] = $path;
1073                }
1074            }
1075
1076            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1077                $paths = array_merge( $paths, self::playlist_block_paths( $block['innerBlocks'], $path ) );
1078            }
1079        }
1080
1081        return $paths;
1082    }
1083
1084    /**
1085     * Every playlist the dashboard lists, keyed by playlist key: the published
1086     * ones from the index, then those in unpublished posts the user can edit.
1087     *
1088     * @return array[] Records as Channel::playlists() lists them, plus `status`.
1089     */
1090    public static function records() {
1091        $records = array();
1092        foreach ( Channel::playlists() as $playlist ) {
1093            $playlist['status']          = 'publish';
1094            $records[ $playlist['key'] ] = $playlist;
1095        }
1096
1097        foreach ( self::unpublished_posts() as $post ) {
1098            if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1099                continue;
1100            }
1101            foreach ( Playlist_Index::extract_records( $post ) as $key => $record ) {
1102                $key = (string) $key;
1103                if ( isset( $records[ $key ] ) ) {
1104                    $key .= '-' . $post->ID;
1105                }
1106                $records[ $key ] = array_merge(
1107                    $record,
1108                    array(
1109                        'key'     => $key,
1110                        'status'  => $post->post_status,
1111                        'updated' => self::modified_time( $post ),
1112                    )
1113                );
1114            }
1115        }
1116
1117        return $records;
1118    }
1119
1120    /**
1121     * Unpublished posts that hold a Video Playlist block, most recently modified first.
1122     *
1123     * @return WP_Post[]
1124     */
1125    private static function unpublished_posts() {
1126        $add_content_clause = function ( $where ) {
1127            global $wpdb;
1128
1129            return $where . $wpdb->prepare(
1130                " AND {$wpdb->posts}.post_content LIKE %s",
1131                '%' . $wpdb->esc_like( '<!-- wp:' . Playlist_Index::BLOCK_NAME ) . '%'
1132            );
1133        };
1134
1135        add_filter( 'posts_where', $add_content_clause );
1136        $query = new WP_Query(
1137            array(
1138                'post_type'              => self::post_types(),
1139                'post_status'            => array_values( array_diff( self::STATUSES, array( 'publish' ) ) ),
1140                'posts_per_page'         => self::UNPUBLISHED_LIMIT,
1141                'orderby'                => 'modified',
1142                'order'                  => 'DESC',
1143                'no_found_rows'          => true,
1144                'ignore_sticky_posts'    => true,
1145                'suppress_filters'       => false,
1146                'update_post_meta_cache' => false,
1147                'update_post_term_cache' => false,
1148            )
1149        );
1150        remove_filter( 'posts_where', $add_content_clause );
1151
1152        return array_values(
1153            array_filter(
1154                $query->posts,
1155                function ( $post ) {
1156                    return $post instanceof WP_Post;
1157                }
1158            )
1159        );
1160    }
1161
1162    /**
1163     * Post types that hold playlists, as Playlist_Index indexes them.
1164     *
1165     * @return string[]
1166     */
1167    private static function post_types() {
1168        return array_values(
1169            array_filter(
1170                get_post_types( array( 'public' => true ) ),
1171                function ( $post_type ) {
1172                    return 'attachment' !== $post_type;
1173                }
1174            )
1175        );
1176    }
1177
1178    /**
1179     * When a post was last modified, as a Unix timestamp.
1180     *
1181     * A draft that was never updated has no GMT modified time, so fall back to the local one.
1182     *
1183     * @param WP_Post $post Post.
1184     * @return int
1185     */
1186    private static function modified_time( WP_Post $post ) {
1187        if ( '0000-00-00 00:00:00' !== $post->post_modified_gmt ) {
1188            return (int) strtotime( $post->post_modified_gmt . ' UTC' );
1189        }
1190
1191        return (int) strtotime( get_gmt_from_date( $post->post_modified ) . ' UTC' );
1192    }
1193
1194    /**
1195     * Look a playlist up by key.
1196     *
1197     * @param string $key Playlist key.
1198     * @return array|null
1199     */
1200    public static function find_record( $key ) {
1201        $records = self::records();
1202
1203        return $records[ $key ] ?? null;
1204    }
1205
1206    /**
1207     * The record of the playlist block at a path in a post, whatever its key turned out to be.
1208     *
1209     * @param int   $post_id Post id.
1210     * @param int[] $path    Indexes leading to the block through `innerBlocks`.
1211     * @return array|null
1212     */
1213    private static function record_for_block( $post_id, array $path ) {
1214        $post = get_post( $post_id );
1215        if ( ! $post instanceof WP_Post ) {
1216            return null;
1217        }
1218        $blocks = parse_blocks( $post->post_content );
1219
1220        foreach ( self::records() as $record ) {
1221            if ( (int) $record['post_id'] === (int) $post_id && self::find_block_path( $blocks, $record['key'], $post_id ) === $path ) {
1222                return $record;
1223            }
1224        }
1225
1226        return null;
1227    }
1228
1229    /**
1230     * The attributes of a record's block, read from its post.
1231     *
1232     * @param array $record Playlist record.
1233     * @return array
1234     */
1235    private static function block_attrs( array $record ) {
1236        $post = get_post( $record['post_id'] );
1237        if ( ! $post instanceof WP_Post ) {
1238            return array();
1239        }
1240
1241        $blocks = parse_blocks( $post->post_content );
1242        $path   = self::find_block_path( $blocks, $record['key'], $post->ID );
1243        if ( null === $path ) {
1244            return array();
1245        }
1246        $attrs = self::block_at( $blocks, $path )['attrs'] ?? array();
1247
1248        return is_array( $attrs ) ? $attrs : array();
1249    }
1250
1251    /**
1252     * Shape a playlist record for the REST response.
1253     *
1254     * @param array $record Record as records() lists it.
1255     * @return array
1256     */
1257    public static function prepare_item( array $record ) {
1258        $post    = get_post( $record['post_id'] );
1259        $videos  = array_map(
1260            function ( $entry ) {
1261                return array(
1262                    'guid'       => $entry['guid'],
1263                    'durationMs' => $entry['durationMs'],
1264                    'height'     => $entry['height'],
1265                );
1266            },
1267            $record['videos']
1268        );
1269        $is_post = $post instanceof WP_Post;
1270        $status  = $record['status'] ?? ( $is_post ? $post->post_status : 'publish' );
1271        $type    = $is_post ? get_post_type_object( $post->post_type ) : null;
1272
1273        if ( 'publish' === $status ) {
1274            $url = Channel::is_enabled() ? Channel::playlist_url( $record['key'] ) : ( $is_post ? (string) get_permalink( $post ) : '' );
1275        } else {
1276            $url = $is_post ? (string) get_preview_post_link( $post ) : '';
1277        }
1278
1279        return array(
1280            'key'         => $record['key'],
1281            'title'       => $record['title'],
1282            'description' => $record['description'],
1283            'videos'      => $videos,
1284            'videoCount'  => count( $videos ),
1285            'durationMs'  => array_sum( array_column( $videos, 'durationMs' ) ),
1286            'updated'     => gmdate( 'c', $record['updated'] ?? ( $is_post ? self::modified_time( $post ) : 0 ) ),
1287            'status'      => $status,
1288            'settings'    => self::settings_from_attrs( self::block_attrs( $record ) ),
1289            'post'        => array(
1290                'id'        => (int) $record['post_id'],
1291                'title'     => $is_post ? html_entity_decode( get_the_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) ) : '',
1292                'type'      => $is_post ? $post->post_type : '',
1293                'typeLabel' => $type ? $type->labels->singular_name : '',
1294                'editUrl'   => $is_post ? (string) get_edit_post_link( $post, 'raw' ) : '',
1295            ),
1296            'url'         => $url,
1297            'canEdit'     => $is_post && current_user_can( 'edit_post', $post->ID ),
1298            'canPublish'  => $type && current_user_can( $type->cap->publish_posts ),
1299        );
1300    }
1301
1302    /**
1303     * The error for publishing without the capability.
1304     *
1305     * @return WP_Error
1306     */
1307    private static function cannot_publish() {
1308        return new WP_Error(
1309            'videopress_playlist_cannot_publish',
1310            __( 'You aren’t allowed to publish this playlist.', 'jetpack-videopress-pkg' ),
1311            array( 'status' => rest_authorization_required_code() )
1312        );
1313    }
1314
1315    /**
1316     * The not-found error shared by every playlist route.
1317     *
1318     * @return WP_Error
1319     */
1320    private static function not_found() {
1321        return new WP_Error(
1322            'videopress_playlist_not_found',
1323            __( 'Playlist not found.', 'jetpack-videopress-pkg' ),
1324            array( 'status' => 404 )
1325        );
1326    }
1327}