Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.25% covered (warning)
56.25%
153 / 272
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Media_Summary
56.25% covered (warning)
56.25%
153 / 272
64.29% covered (warning)
64.29%
9 / 14
1103.92
0.00% covered (danger)
0.00%
0 / 1
 get
46.02% covered (danger)
46.02%
81 / 176
0.00% covered (danger)
0.00%
0 / 1
729.45
 set_video
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 get_videopress_video
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_block_videos
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 get_core_video
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 https
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ssl_img
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_video_poster
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 clean_text
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_excerpt
39.13% covered (danger)
39.13%
9 / 23
0.00% covered (danger)
0.00%
0 / 1
7.61
 split_content_in_words
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_word_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_word_remaining_count
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_link_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Provides media summary of a post.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
9
10/**
11 * Class Jetpack_Media_Summary
12 *
13 * Priority: embed [video] > gallery > image > text
14 */
15class Jetpack_Media_Summary {
16
17    /**
18     * Media cache.
19     *
20     * @var array
21     */
22    private static $cache = array();
23
24    /**
25     * Get media summary for a post.
26     *
27     * @param ?int  $post_id Post ID.
28     * @param int   $blog_id Blog ID, if applicable.
29     * @param array $args {
30     *      Optional. An array of arguments.
31     *      @type int $max_words Maximum number of words.
32     *      @type int $max_chars Maximum number of characters.
33     *      @type bool $include_excerpt Whether to compute the excerpt and return it. Default true.
34     *      @type bool $include_counts  Whether to compute word/link counts. Default true.
35     * }
36     *
37     * @return array|mixed|void
38     */
39    public static function get( ?int $post_id, int $blog_id = 0, array $args = array() ) {
40        $post_id = (int) $post_id;
41
42        $defaults = array(
43            'max_words'       => 16,
44            'max_chars'       => 256,
45            'include_excerpt' => true,
46            'include_counts'  => true,
47        );
48        $args     = wp_parse_args( $args, $defaults );
49
50        $switched = false;
51        if ( ! empty( $blog_id ) && get_current_blog_id() !== $blog_id && function_exists( 'switch_to_blog' ) ) {
52            switch_to_blog( $blog_id );
53            $switched = true;
54        } else {
55            $blog_id = get_current_blog_id();
56        }
57
58        $cache_key = "{$blog_id}_{$post_id}_{$args['max_words']}_{$args['max_chars']}_"
59            . (int) $args['include_excerpt'] . '_' . (int) $args['include_counts'];
60        if ( isset( self::$cache[ $cache_key ] ) ) {
61            if ( $switched ) {
62                restore_current_blog();
63            }
64            return self::$cache[ $cache_key ];
65        }
66
67        if ( ! class_exists( 'Jetpack_Media_Meta_Extractor' ) ) {
68            require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-extractor.php';
69        }
70
71        $post      = get_post( $post_id );
72        $permalink = get_permalink( $post_id );
73
74        $return = array(
75            'type'       => 'standard',
76            'permalink'  => $permalink,
77            'image'      => '',
78            'excerpt'    => '',
79            'word_count' => 0,
80            'secure'     => array(
81                'image' => '',
82            ),
83            'count'      => array(
84                'image'          => 0,
85                'video'          => 0,
86                'word'           => 0,
87                'word_remaining' => 0,
88                'link'           => 0,
89            ),
90        );
91
92        if ( $post instanceof WP_Post && empty( $post->post_password ) ) {
93            if ( $args['include_excerpt'] ) {
94                $return['excerpt'] = self::get_excerpt( $post->post_content, $post->post_excerpt, $args['max_words'], $args['max_chars'], $post );
95            }
96            if ( $args['include_counts'] ) {
97                $return['count']['word'] = self::get_word_count( $post->post_content );
98                $return['count']['link'] = self::get_link_count( $post->post_content );
99                // Only compute word_remaining if we have an excerpt. If not, leave the default of 0.
100                if ( $args['include_excerpt'] && '' !== $return['excerpt'] ) {
101                    $return['count']['word_remaining'] = self::get_word_remaining_count( $post->post_content, $return['excerpt'] );
102                }
103            }
104        }
105
106        $extract = Jetpack_Media_Meta_Extractor::extract( $blog_id, $post_id, Jetpack_Media_Meta_Extractor::ALL );
107
108        $block_videos = array();
109        if ( $post instanceof WP_Post && has_blocks( $post->post_content ) ) {
110            $block_videos = self::get_block_videos( parse_blocks( $post->post_content ) );
111        }
112
113        if ( empty( $extract['has'] ) && empty( $block_videos ) ) {
114            if ( $switched ) {
115                restore_current_blog();
116            }
117            self::$cache[ $cache_key ] = $return;
118            return $return;
119        }
120
121        // Prioritize [some] video embeds.
122        if ( ! empty( $extract['has']['shortcode'] ) ) {
123            foreach ( $extract['shortcode'] as $type => $data ) {
124                switch ( $type ) {
125                    case 'videopress':
126                    case 'wpvideo':
127                        if ( 0 === $return['count']['video'] ) {
128                            // If there is no id on the video, then let's just skip this.
129                            if ( ! isset( $data['id'][0] ) ) {
130                                break;
131                            }
132
133                            $video = self::get_videopress_video( $data['id'][0] );
134                            if ( null === $video ) {
135                                break;
136                            }
137                            $return = self::set_video( $return, $video );
138                        }
139                        ++$return['count']['video'];
140                        break;
141                    case 'youtube':
142                        if ( 0 === $return['count']['video'] ) {
143                            if ( ! isset( $extract['shortcode']['youtube']['id'][0] ) ) {
144                                break;
145                            }
146                            $return['type']            = 'video';
147                            $return['video']           = esc_url_raw( 'http://www.youtube.com/watch?feature=player_embedded&v=' . $extract['shortcode']['youtube']['id'][0] );
148                            $return['image']           = self::get_video_poster( 'youtube', $extract['shortcode']['youtube']['id'][0] );
149                            $return['secure']['video'] = self::https( $return['video'] );
150                            $return['secure']['image'] = self::https( $return['image'] );
151                        }
152                        ++$return['count']['video'];
153                        break;
154                    case 'vimeo':
155                        if ( 0 === $return['count']['video'] ) {
156                            if ( ! isset( $extract['shortcode']['vimeo']['id'][0] ) ) {
157                                break;
158                            }
159                            $return['type']            = 'video';
160                            $return['video']           = esc_url_raw( 'http://vimeo.com/' . $extract['shortcode']['vimeo']['id'][0] );
161                            $return['secure']['video'] = self::https( $return['video'] );
162
163                            $poster_image = get_post_meta( $post_id, 'vimeo_poster_image', true );
164                            if ( ! empty( $poster_image ) ) {
165                                $return['image']           = $poster_image;
166                                $poster_url_parts          = wp_parse_url( $poster_image );
167                                $return['secure']['image'] = 'https://secure-a.vimeocdn.com' . $poster_url_parts['path'];
168                            }
169                        }
170                        ++$return['count']['video'];
171                        break;
172                }
173            }
174        }
175
176        if ( ! empty( $extract['has']['embed'] ) ) {
177            foreach ( $extract['embed']['url'] as $embed ) {
178                if ( preg_match( '/((youtube|vimeo|dailymotion)\.com|youtu.be)/', $embed ) ) {
179                    if ( 0 === $return['count']['video'] ) {
180                        $return['type']            = 'video';
181                        $return['video']           = 'http://' . $embed;
182                        $return['secure']['video'] = self::https( $return['video'] );
183                        if ( str_contains( $embed, 'youtube' ) ) {
184                            $return['image']           = self::get_video_poster( 'youtube', jetpack_get_youtube_id( $return['video'] ) );
185                            $return['secure']['image'] = self::https( $return['image'] );
186                        } elseif ( str_contains( $embed, 'youtu.be' ) ) {
187                            $youtube_id                = jetpack_get_youtube_id( $return['video'] );
188                            $return['video']           = 'http://youtube.com/watch?v=' . $youtube_id . '&feature=youtu.be';
189                            $return['secure']['video'] = self::https( $return['video'] );
190                            $return['image']           = self::get_video_poster( 'youtube', jetpack_get_youtube_id( $return['video'] ) );
191                            $return['secure']['image'] = self::https( $return['image'] );
192                        } elseif ( str_contains( $embed, 'vimeo' ) ) {
193                            $poster_image = get_post_meta( $post_id, 'vimeo_poster_image', true );
194                            if ( ! empty( $poster_image ) ) {
195                                $return['image']           = $poster_image;
196                                $poster_url_parts          = wp_parse_url( $poster_image );
197                                $return['secure']['image'] = 'https://secure-a.vimeocdn.com' . $poster_url_parts['path'];
198                            }
199                        } elseif ( str_contains( $embed, 'dailymotion' ) ) {
200                            $return['image']           = str_replace( 'dailymotion.com/video/', 'dailymotion.com/thumbnail/video/', $embed );
201                            $return['image']           = wp_parse_url( $return['image'], PHP_URL_SCHEME ) === null ? 'http://' . $return['image'] : $return['image'];
202                            $return['secure']['image'] = self::https( $return['image'] );
203                        }
204                    }
205                    ++$return['count']['video'];
206                }
207            }
208        }
209
210        $videopress_looked_up = false;
211        foreach ( $block_videos as $video ) {
212            if ( 0 === $return['count']['video'] ) {
213                // Each lookup can be a remote request, so only try the first VideoPress video.
214                if ( isset( $video['guid'] ) ) {
215                    if ( $videopress_looked_up ) {
216                        continue;
217                    }
218                    $videopress_looked_up = true;
219                    $video                = self::get_videopress_video( $video['guid'] );
220                    if ( null === $video ) {
221                        continue;
222                    }
223                }
224                $return = self::set_video( $return, $video );
225            }
226            ++$return['count']['video'];
227        }
228
229        // Do we really want to make the video the primary focus of the post?
230        if ( 'video' === $return['type'] ) {
231            $content              = wpautop( wp_strip_all_tags( $post->post_content ) );
232            $paragraphs           = explode( '</p>', $content );
233            $number_of_paragraphs = 0;
234
235            foreach ( $paragraphs as $i => $paragraph ) {
236                // Don't include blank lines as a paragraph.
237                if ( '' === trim( $paragraph ) ) {
238                    unset( $paragraphs[ $i ] );
239                    continue;
240                }
241                ++$number_of_paragraphs;
242            }
243
244            $number_of_paragraphs -= $return['count']['video']; // subtract amount for videos.
245
246            // More than 2 paragraph? The video is not the primary focus so we can do some more analysis.
247            if ( $number_of_paragraphs > 2 ) {
248                $return['type'] = 'standard';
249            }
250        }
251
252        // If we don't have any prioritized embed...
253        if ( 'standard' === $return['type'] ) {
254            if ( ( ! empty( $extract['has']['gallery'] ) || ! empty( $extract['shortcode']['gallery']['count'] ) ) && ! empty( $extract['image'] ) ) {
255                // ... Then we prioritize galleries first (multiple images returned)
256                $return['type']   = 'gallery';
257                $return['images'] = $extract['image'];
258                foreach ( $return['images'] as $image ) {
259                    $return['secure']['images'][] = array( 'url' => self::ssl_img( $image['url'] ) );
260                    ++$return['count']['image'];
261                }
262            } elseif ( ! empty( $extract['has']['image'] ) ) {
263                // ... Or we try and select a single image that would make sense.
264                $content              = wpautop( wp_strip_all_tags( $post->post_content ) );
265                $paragraphs           = explode( '</p>', $content );
266                $number_of_paragraphs = 0;
267
268                foreach ( $paragraphs as $i => $paragraph ) {
269                    // Don't include 'actual' captions as a paragraph.
270                    if ( str_contains( $paragraph, '[caption' ) ) {
271                        unset( $paragraphs[ $i ] );
272                        continue;
273                    }
274                    // Don't include blank lines as a paragraph.
275                    if ( '' === trim( $paragraph ) ) {
276                        unset( $paragraphs[ $i ] );
277                        continue;
278                    }
279                    ++$number_of_paragraphs;
280                }
281
282                // @phan-suppress-next-line PhanTypeMismatchDimFetch -- Phan is understandably confused, as $extract has many forms, including this one.
283                if ( ! empty( $extract['image'][0]['url'] ) ) {
284                    $return['image']           = $extract['image'][0]['url'];
285                    $return['secure']['image'] = self::ssl_img( $return['image'] );
286                    ++$return['count']['image'];
287                }
288
289                if ( $number_of_paragraphs <= 2 && is_countable( $extract['image'] ) && 1 === count( $extract['image'] ) ) {
290                    // If we have lots of text or images, let's not treat it as an image post, but return its first image.
291                    $return['type'] = 'image';
292                }
293            }
294        }
295
296        if ( $switched ) {
297            restore_current_blog();
298        }
299
300        /**
301         * Allow a theme or plugin to inspect and ultimately change the media summary.
302         *
303         * @since 4.4.0
304         *
305         * @param array $data The calculated media summary data.
306         * @param int $post_id The id of the post this data applies to.
307         */
308        $return = apply_filters( 'jetpack_media_summary_output', $return, $post_id );
309
310        self::$cache[ $cache_key ] = $return;
311
312        return $return;
313    }
314
315    /**
316     * Set a video as the post's primary media.
317     *
318     * @param array $summary Media summary being built.
319     * @param array $video   Video data with `video`, and optionally `video_type` and `image` keys.
320     *
321     * @return array Media summary.
322     */
323    private static function set_video( $summary, $video ) {
324        $summary['type']            = 'video';
325        $summary['video']           = esc_url_raw( $video['video'] );
326        $summary['secure']['video'] = $summary['video'];
327        if ( ! empty( $video['video_type'] ) ) {
328            $summary['video_type'] = $video['video_type'];
329        }
330        if ( ! empty( $video['image'] ) ) {
331            $summary['image']           = $video['image'];
332            $summary['secure']['image'] = $video['image'];
333        }
334        return $summary;
335    }
336
337    /**
338     * Get the MP4 file and poster of a VideoPress video.
339     *
340     * @param string $guid VideoPress video GUID.
341     *
342     * @return array|null Video data, or null if the video is private or has no MP4 file or poster.
343     */
344    private static function get_videopress_video( $guid ) {
345        if ( ! function_exists( 'videopress_get_video_details' ) ) {
346            return null;
347        }
348
349        $video_info = videopress_get_video_details( $guid );
350        if ( ! $video_info instanceof stdClass
351            || ! empty( $video_info->is_private )
352            || empty( $video_info->files->std->mp4 )
353            || empty( $video_info->poster )
354        ) {
355            return null;
356        }
357
358        return array(
359            'video'      => sprintf( 'https://videos.files.wordpress.com/%1$s/%2$s', $guid, $video_info->files->std->mp4 ),
360            'video_type' => 'video/mp4',
361            'image'      => $video_info->poster,
362        );
363    }
364
365    /**
366     * Find the videos in VideoPress and core Video blocks, including nested ones.
367     *
368     * @param array $blocks Parsed blocks.
369     *
370     * @return array[] A `guid` for each VideoPress video, or video data for each self-hosted one.
371     */
372    private static function get_block_videos( $blocks ) {
373        $videos = array();
374        foreach ( $blocks as $block ) {
375            if ( in_array( $block['blockName'], array( 'videopress/video', 'core/video' ), true ) ) {
376                $guid = $block['attrs']['guid'] ?? '';
377                if ( is_string( $guid ) && '' !== $guid ) {
378                    $videos[] = array( 'guid' => $guid );
379                } elseif ( 'core/video' === $block['blockName'] ) {
380                    $video = self::get_core_video( $block['innerHTML'] );
381                    if ( null !== $video ) {
382                        $videos[] = $video;
383                    }
384                }
385            }
386
387            if ( ! empty( $block['innerBlocks'] ) ) {
388                $videos = array_merge( $videos, self::get_block_videos( $block['innerBlocks'] ) );
389            }
390        }
391        return $videos;
392    }
393
394    /**
395     * Get the file and poster of a self-hosted video from a core Video block.
396     *
397     * @param string $html The block's saved markup.
398     *
399     * @return array|null Video data, or null without a poster (share cards need an image) or a known video file type.
400     */
401    private static function get_core_video( $html ) {
402        $tags = new WP_HTML_Tag_Processor( $html );
403        if ( ! $tags->next_tag( 'video' ) ) {
404            return null;
405        }
406
407        $src    = $tags->get_attribute( 'src' );
408        $poster = $tags->get_attribute( 'poster' );
409        if ( ! is_string( $src ) || '' === $src || ! is_string( $poster ) || '' === $poster ) {
410            return null;
411        }
412
413        // Check against all known types, not the upload allow-list, which hosts can restrict.
414        $filetype = wp_check_filetype( (string) wp_parse_url( $src, PHP_URL_PATH ), wp_get_mime_types() );
415        // Open Graph would otherwise label the file text/html.
416        if ( ! is_string( $filetype['type'] ) || ! str_starts_with( $filetype['type'], 'video/' ) ) {
417            return null;
418        }
419
420        return array(
421            'video'      => $src,
422            'video_type' => $filetype['type'],
423            'image'      => $poster,
424        );
425    }
426
427    /**
428     * Converts http to https://
429     *
430     * @param string $str URL.
431     *
432     * @return string URL.
433     */
434    public static function https( $str ) {
435        return str_replace( 'http://', 'https://', $str );
436    }
437
438    /**
439     * Returns a Photonized version of the URL.
440     *
441     * @param string $url URL.
442     *
443     * @return string URL.
444     */
445    public static function ssl_img( $url ) {
446        if ( str_contains( $url, 'files.wordpress.com' ) ) {
447            return self::https( $url );
448        } else {
449            return self::https( Image_CDN_Core::cdn_url( $url ) );
450        }
451    }
452
453    /**
454     * Get the video poster.
455     *
456     * @param string $type Video service.
457     * @param string $id Video ID for the service.
458     *
459     * @return string URL of image thumbnail for the video.
460     */
461    public static function get_video_poster( $type, $id ) {
462        if ( 'videopress' === $type ) {
463            if ( function_exists( 'video_get_highest_resolution_image_url' ) ) {
464                return video_get_highest_resolution_image_url( $id );
465            } elseif ( class_exists( 'VideoPress_Video' ) ) {
466                $video = new VideoPress_Video( $id );
467                return $video->poster_frame_uri;
468            }
469        } elseif ( 'youtube' === $type ) {
470            return 'http://img.youtube.com/vi/' . $id . '/0.jpg';
471        }
472    }
473
474    /**
475     * Clean text of shortcodes and tags.
476     *
477     * @param string $text Dirty text.
478     * @param bool   $preserve_urls When true, keep http(s) URLs in the text instead of stripping them. Default false.
479     *
480     * @return string Clean text.
481     */
482    public static function clean_text( $text, $preserve_urls = false ) {
483        $text = strip_shortcodes( wp_strip_all_tags( $text ) );
484
485        if ( ! $preserve_urls ) {
486            $text = preg_replace( '@https?://[\S]+@', '', $text );
487        }
488
489        return trim( preg_replace( '/[\s]+/', ' ', $text ) );
490    }
491
492    /**
493     * Retrieve an excerpt for the post summary.
494     *
495     * This function works around a suspected problem with Core. If resolved, this function should be simplified.
496     *
497     * @link https://github.com/Automattic/jetpack/pull/8510
498     * @link https://core.trac.wordpress.org/ticket/42814
499     *
500     * @param  string  $post_content The post's content.
501     * @param  string  $post_excerpt The post's excerpt. Empty if none was explicitly set.
502     * @param  int     $max_words Maximum number of words for the excerpt. Used on wp.com. Default 16.
503     * @param  int     $max_chars Maximum characters in the excerpt. Used on wp.com. Default 256.
504     * @param  WP_Post $requested_post The post object.
505     * @param  bool    $preserve_urls When true, keep http(s) URLs in the excerpt instead of stripping them. Default false.
506     * @return string Post excerpt.
507     **/
508    public static function get_excerpt( $post_content, $post_excerpt, $max_words = 16, $max_chars = 256, $requested_post = null, $preserve_urls = false ) {
509        global $post;
510        $original_post = $post; // Saving the global for later use.
511        if ( empty( $post_excerpt ) && function_exists( 'wpcom_enhanced_excerpt_extract_excerpt' ) ) {
512            return self::clean_text(
513                wpcom_enhanced_excerpt_extract_excerpt(
514                    array(
515                        'text'                => $post_content,
516                        'excerpt_only'        => true,
517                        'show_read_more'      => false,
518                        'max_words'           => $max_words,
519                        'max_chars'           => $max_chars,
520                        'read_more_threshold' => 25,
521                    )
522                ),
523                $preserve_urls
524            );
525        } elseif ( $requested_post instanceof WP_Post ) {
526            // @todo Refactor to not need to override the global.
527            // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited
528            $post = $requested_post; // setup_postdata does not set the global.
529            setup_postdata( $post );
530            /** This filter is documented in core/src/wp-includes/post-template.php */
531            $post_excerpt = apply_filters( 'get_the_excerpt', $post_excerpt, $post );
532            // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited
533            $post = $original_post; // wp_reset_postdata uses the $post global.
534            wp_reset_postdata();
535            return self::clean_text( $post_excerpt, $preserve_urls );
536        }
537        return '';
538    }
539
540    /**
541     * Split a string into an array of words.
542     *
543     * @param string $text Post content or excerpt.
544     *
545     * @return array Array of words.
546     */
547    public static function split_content_in_words( $text ) {
548        $words = preg_split( '/[\s!?;,.]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
549
550        // Return an empty array if the split above fails.
551        return $words ? $words : array();
552    }
553
554    /**
555     * Get the word count.
556     *
557     * @param string $post_content Post content.
558     *
559     * @return int Word count.
560     */
561    public static function get_word_count( $post_content ) {
562        return count( self::split_content_in_words( self::clean_text( $post_content ) ) );
563    }
564
565    /**
566     * Get remainder word count (after the excerpt).
567     *
568     * @param string $post_content Post content.
569     * @param string $excerpt_content Excerpt content.
570     *
571     * @return int Number of words after the excerpt.
572     */
573    public static function get_word_remaining_count( $post_content, $excerpt_content ) {
574        $content_word_count = count( self::split_content_in_words( self::clean_text( $post_content ) ) );
575        $excerpt_word_count = count( self::split_content_in_words( self::clean_text( $excerpt_content ) ) );
576
577        return $content_word_count - $excerpt_word_count;
578    }
579
580    /**
581     * Counts the number of links in a post.
582     *
583     * @param string $post_content Post content.
584     *
585     * @return false|int Number of links.
586     */
587    public static function get_link_count( $post_content ) {
588        return preg_match_all( '/\<a[\> ]/', $post_content, $matches );
589    }
590}