": [ { type, label, title, url, edit } ] }. * * @package StreamSera */ namespace StreamSera\Modules; use StreamSera\PostTypes\Player; use StreamSera\PostTypes\Station; use WP_REST_Response; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Player usage module. */ class Player_Usage { const NS = 'streamsera/v1'; const OPTION = 'streamsera_player_usage'; /** * Hook in. */ public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); // Anything that can add or remove a player from the site clears the cached map. add_action( 'save_post', array( $this, 'maybe_flush_post' ), 10, 2 ); add_action( 'deleted_post', array( __CLASS__, 'flush' ) ); add_action( 'trashed_post', array( __CLASS__, 'flush' ) ); add_action( 'untrashed_post', array( __CLASS__, 'flush' ) ); add_action( 'update_option_sidebars_widgets', array( __CLASS__, 'flush' ) ); add_action( 'update_option_widget_block', array( __CLASS__, 'flush' ) ); add_action( 'update_option_widget_streamsera_player', array( __CLASS__, 'flush' ) ); add_action( 'update_option_streamsera_settings', array( __CLASS__, 'flush' ) ); add_action( 'updated_post_meta', array( $this, 'maybe_flush_meta' ), 10, 3 ); } /** * Who may see where players are used: anyone who can edit posts. * * @return bool */ public function can_read() { return current_user_can( 'edit_posts' ); } /** * Routes. */ public function routes() { register_rest_route( self::NS, '/players/usage', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_read' ), 'args' => array( 'refresh' => array( 'type' => 'boolean', 'default' => false, ), ), 'callback' => array( $this, 'rest' ), ) ); } /** * REST callback. * * @param \WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest( $request ) { if ( rest_sanitize_boolean( $request->get_param( 'refresh' ) ) ) { self::flush(); } $map = self::map(); // Only show edit links the current user may follow. foreach ( $map as $player => $places ) { foreach ( $places as $i => $place ) { if ( ! empty( $place['post'] ) && ! current_user_can( 'edit_post', (int) $place['post'] ) ) { $map[ $player ][ $i ]['edit'] = ''; } unset( $map[ $player ][ $i ]['post'] ); } } return new WP_REST_Response( (object) $map ); } /** * Clear the cached map. */ public static function flush() { delete_option( self::OPTION ); } /** * Clear the map when published content changes (not for revisions or our own records). * * @param int $post_id Post id. * @param \WP_Post $post Post. */ public function maybe_flush_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( in_array( $post->post_type, array( Station::POST_TYPE, Player::POST_TYPE ), true ) ) { return; } self::flush(); } /** * Page builders save their layout in post meta after the post itself. * * @param int $meta_id Meta id. * @param int $post_id Post id. * @param string $meta_key Key. */ public function maybe_flush_meta( $meta_id, $post_id, $meta_key ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- action signature. if ( in_array( $meta_key, self::builder_meta_keys(), true ) ) { self::flush(); } } /** * Post meta keys page builders keep their layouts in. * * @return string[] */ private static function builder_meta_keys() { return array( '_elementor_data', '_bricks_page_content_2', '_breakdance_data', '_fl_builder_data', 'ct_builder_shortcodes', '_oxygen_data' ); } /** * The usage map: player id → list of places. * * @return array>> */ public static function map() { $cached = get_option( self::OPTION ); if ( is_array( $cached ) && isset( $cached['map'] ) && is_array( $cached['map'] ) ) { return $cached['map']; } $map = self::build(); update_option( self::OPTION, array( 'map' => $map, 'time' => time(), ), false ); return $map; } /** * Places one player is used in. * * @param int $player_id Player. * @return array */ public static function for_player( $player_id ) { $map = self::map(); return isset( $map[ $player_id ] ) ? $map[ $player_id ] : array(); } /** * Scan the site. * * @return array */ private static function build() { global $wpdb; $map = array(); $add = static function ( $player, $place ) use ( &$map ) { $player = (int) $player; if ( $player <= 0 ) { return; } $key = $place['type'] . ':' . ( $place['post'] ?? $place['label'] ); if ( ! isset( $map[ $player ] ) ) { $map[ $player ] = array(); } if ( ! isset( $map[ $player ][ $key ] ) ) { $map[ $player ][ $key ] = $place; } }; // 1. Published content (posts, pages, public custom types, synced patterns and site templates). $skip = array( Player::POST_TYPE, Station::POST_TYPE, 'revision', 'nav_menu_item', 'attachment' ); $types = array_diff( array_merge( get_post_types( array( 'public' => true ) ), array( 'wp_block', 'wp_template', 'wp_template_part' ) ), $skip ); if ( $types ) { $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $keys = self::builder_meta_keys(); $kin = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders are built above (one array of replacements); one-off scan, cached. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_type, p.post_content AS content FROM {$wpdb->posts} p WHERE p.post_status = 'publish' AND p.post_type IN ($in) AND p.post_content LIKE %s UNION ALL SELECT p.ID, p.post_type, m.meta_value AS content FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID WHERE p.post_status = 'publish' AND p.post_type IN ($in) AND m.meta_key IN ($kin) AND m.meta_value LIKE %s LIMIT 2000", array_merge( array_values( $types ), array( '%streamsera%' ), array_values( $types ), $keys, array( '%streamsera%' ) ) ) ); // phpcs:enable foreach ( (array) $rows as $row ) { $ids = self::ids_in( (string) $row->content ); if ( ! $ids ) { continue; } $place = self::post_place( (int) $row->ID, (string) $row->post_type ); foreach ( $ids as $id ) { $add( $id, $place ); } } } // 2. Widgets: classic player widgets and block widgets, only when placed in an active sidebar. $sidebars = (array) get_option( 'sidebars_widgets', array() ); $names = array(); foreach ( (array) ( $GLOBALS['wp_registered_sidebars'] ?? array() ) as $sid => $sb ) { $names[ $sid ] = isset( $sb['name'] ) ? $sb['name'] : $sid; } $classic = (array) get_option( 'widget_streamsera_player', array() ); $blocks = (array) get_option( 'widget_block', array() ); foreach ( $sidebars as $sid => $widgets ) { if ( 'wp_inactive_widgets' === $sid || ! is_array( $widgets ) ) { continue; } $area = isset( $names[ $sid ] ) ? $names[ $sid ] : ucwords( str_replace( array( '-', '_' ), ' ', (string) $sid ) ); foreach ( $widgets as $widget ) { $ids = array(); if ( preg_match( '/^streamsera_player-(\d+)$/', (string) $widget, $m ) && isset( $classic[ (int) $m[1] ]['player'] ) ) { $ids = array( (int) $classic[ (int) $m[1] ]['player'] ); } elseif ( preg_match( '/^block-(\d+)$/', (string) $widget, $m ) && isset( $blocks[ (int) $m[1] ]['content'] ) ) { $ids = self::ids_in( (string) $blocks[ (int) $m[1] ]['content'] ); } foreach ( $ids as $id ) { $add( $id, array( 'type' => 'widget', /* translators: %s: widget area (sidebar) name. */ 'label' => sprintf( __( '%s widget', 'streamsera' ), $area ), 'title' => $area, 'url' => '', 'edit' => admin_url( wp_is_block_theme() ? 'site-editor.php' : 'widgets.php' ), ) ); } } } // 3. The listen page. if ( streamsera_get_setting( 'listen_enabled', false ) ) { $listen = (int) streamsera_get_setting( 'listen_player', 0 ); if ( $listen ) { $add( $listen, array( 'type' => 'listen', 'label' => __( 'Listen page', 'streamsera' ), 'title' => __( 'Listen page', 'streamsera' ), 'url' => home_url( '/' . sanitize_title( (string) streamsera_get_setting( 'listen_slug', 'listen' ) ) . '/' ), 'edit' => admin_url( 'admin.php?page=streamsera#/settings' ), ) ); } } $out = array(); foreach ( $map as $player => $places ) { $out[ $player ] = array_values( $places ); } /** * Filter where players are used (add-ons add their own places, e.g. a site-wide bar). * * Each place: { type, label, title, url, edit }. * * @param array $out Player id → places. */ return (array) apply_filters( 'streamsera_player_usage', $out ); } /** * A post as a place. * * @param int $post_id Post id. * @param string $type Post type. * @return array */ private static function post_place( $post_id, $type ) { $title = get_the_title( $post_id ); $title = '' === trim( wp_strip_all_tags( $title ) ) ? __( '(no title)', 'streamsera' ) : wp_strip_all_tags( $title ); $obj = get_post_type_object( $type ); $kind = in_array( $type, array( 'wp_template', 'wp_template_part' ), true ) ? 'template' : ( 'wp_block' === $type ? 'pattern' : 'post' ); $url = 'post' === $kind ? (string) get_permalink( $post_id ) : ''; // The front page reads better as "Home". if ( 'page' === $type && (int) get_option( 'page_on_front' ) === $post_id ) { $title = __( 'Home', 'streamsera' ); } return array( 'type' => $kind, 'post' => $post_id, 'label' => $obj && isset( $obj->labels->singular_name ) ? $obj->labels->singular_name : $type, 'title' => html_entity_decode( $title, ENT_QUOTES, 'UTF-8' ), 'url' => $url, 'edit' => (string) get_edit_post_link( $post_id, 'raw' ), ); } /** * Player ids referenced in a piece of content (shortcodes, blocks, builder data). * * @param string $content Content. * @return int[] */ public static function ids_in( $content ) { if ( false === stripos( $content, 'streamsera' ) ) { return array(); } $ids = array(); // [streamsera id="12"] (also id=12 / id='12'). if ( preg_match_all( '/\[streamsera\s[^\]]*?\bid\s*=\s*["\']?(\d+)/i', $content, $m ) ) { $ids = array_merge( $ids, $m[1] ); } // Builder shortcodes: [streamsera_player player="12"] and friends. if ( preg_match_all( '/\[streamsera_[a-z_]+\s[^\]]*?\bplayer(?:_id)?\s*=\s*["\']?(\d+)/i', $content, $m ) ) { $ids = array_merge( $ids, $m[1] ); } // Blocks: . if ( preg_match_all( '//s', $content, $m ) ) { foreach ( $m[1] as $json ) { $attrs = json_decode( $json, true ); foreach ( array( 'playerId', 'player', 'id' ) as $k ) { if ( is_array( $attrs ) && ! empty( $attrs[ $k ] ) && is_numeric( $attrs[ $k ] ) ) { $ids[] = $attrs[ $k ]; break; } } } } // JSON builders (Elementor, Bricks, Breakdance, Divi 5): a StreamSera element with a "player" setting. if ( preg_match_all( '/streamsera[^{}]{0,400}?"player(?:Id|_id)?"\s*:\s*"?(\d+)/i', $content, $m ) ) { $ids = array_merge( $ids, $m[1] ); } if ( preg_match_all( '/"player(?:Id|_id)?"\s*:\s*"?(\d+)"?[^{}]{0,400}?streamsera/i', $content, $m ) ) { $ids = array_merge( $ids, $m[1] ); } $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) ); // Only ids that are real players. return array_values( array_filter( $ids, static function ( $id ) { return Player::POST_TYPE === get_post_type( $id ); } ) ); } }