value. * Checked in each builder's own code: Elementor, Avada (Fusion Builder), * WPBakery, Divi and Beaver Builder. */ const PAGE_BUILDER_META = array( '_elementor_edit_mode' => 'builder', 'fusion_builder_status' => 'active', '_wpb_vc_js_status' => 'true', '_et_pb_use_builder' => 'on', '_fl_builder_enabled' => '1', ); /** * Scheduled content items. * * @var ScheduledContent */ private $items; /** * Settings sanitizer. * * @var ScheduledContentSanitizer */ private $sanitizer; /** * Constructor. * * @param ScheduledContent $items Scheduled content items. * @param ScheduledContentSanitizer $sanitizer Settings sanitizer. */ public function __construct( ScheduledContent $items, ScheduledContentSanitizer $sanitizer ) { $this->items = $items; $this->sanitizer = $sanitizer; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_filter( 'rest_pre_insert_' . ScheduledContent::POST_TYPE, array( $this, 'validate' ), 10, 2 ); } /** * Registers the field and the target search route. * * @return void */ public function register_routes(): void { register_rest_field( ScheduledContent::POST_TYPE, self::FIELD, array( 'get_callback' => array( $this, 'get_field' ), 'update_callback' => array( $this, 'update_field' ), 'schema' => array( 'description' => __( 'Where and when the item is shown.', 'yangra-conditional' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'properties' => array( 'targets' => array( 'type' => 'array', 'items' => array( 'type' => array( 'integer', 'object' ) ), ), 'position' => array( 'type' => 'string', 'enum' => ScheduledContentSanitizer::POSITIONS, ), 'showAt' => array( 'type' => 'string' ), 'hideAt' => array( 'type' => 'string' ), 'order' => array( 'type' => 'integer' ), ), ), ) ); register_rest_route( SettingsController::ROUTE_NAMESPACE, '/scheduled-content/targets', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'search_targets' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'search' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); } /** * Users who manage scheduled content. * * @return bool */ public function can_manage(): bool { return current_user_can( ScheduledContent::CAPABILITY ); } /** * The field's value: the settings for the editor, and what's read only. * * @param array $post Post data prepared for the response. * @return array */ public function get_field( $post ): array { $post_id = (int) ( $post['id'] ?? 0 ); $settings = $this->items->settings( $post_id ); return $this->prepare( $post_id, $settings ); } /** * Saves the field (already checked by validate()). * * @param mixed $value Submitted value. * @param mixed $post Saved post (WP_Post). * @return true|WP_Error */ public function update_field( $value, $post ) { if ( ! $post instanceof WP_Post || ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to edit this item.', 'yangra-conditional' ), array( 'status' => rest_authorization_required_code() ) ); } $settings = $this->settings_from( (array) $value, $post->ID, 'publish' === $post->post_status ); if ( $settings instanceof WP_Error ) { return $settings; } $this->items->save( $post->ID, $settings ); return true; } /** * Before an item is created or updated through the REST API: refuses * settings that aren't valid, and publishing an item that isn't set up. * * @param mixed $prepared_post Post about to be saved (stdClass). * @param WP_REST_Request $request Request. * @phpstan-param WP_REST_Request> $request * @return mixed The post, or a WP_Error. */ public function validate( $prepared_post, WP_REST_Request $request ) { if ( $prepared_post instanceof WP_Error || ! is_object( $prepared_post ) ) { return $prepared_post; } $post_id = isset( $prepared_post->ID ) ? (int) $prepared_post->ID : 0; $status = isset( $prepared_post->post_status ) ? (string) $prepared_post->post_status : (string) get_post_status( $post_id ); $value = $request->get_param( self::FIELD ); $input = is_array( $value ) ? $value : $this->input_from( $this->items->settings( $post_id ) ); $settings = $this->settings_from( $input, $post_id, 'publish' === $status ); return $settings instanceof WP_Error ? $settings : $prepared_post; } /** * Finds posts the user can edit, of any public post type, by title. * * @param WP_REST_Request $request Request. * @phpstan-param WP_REST_Request> $request * @return WP_REST_Response */ public function search_targets( WP_REST_Request $request ): WP_REST_Response { $query = new WP_Query( array( 'post_type' => $this->target_post_types(), 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 's' => (string) $request->get_param( 'search' ), 'posts_per_page' => self::SEARCH_RESULTS * 3, 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); $found = array(); foreach ( $query->posts as $post ) { if ( $post instanceof WP_Post && current_user_can( 'edit_post', $post->ID ) ) { $found[] = $this->target( $post->ID ); } if ( count( $found ) >= self::SEARCH_RESULTS ) { break; } } return new WP_REST_Response( $found ); } /** * Validates submitted settings and checks the targets the user added. * * @param array $input Submitted value. * @param int $post_id Item ID (0 for a new item). * @param bool $publishing Whether the item is or will be published. * @return array|WP_Error */ private function settings_from( array $input, int $post_id, bool $publishing ) { $input['targets'] = array_map( static function ( $target ) { return is_array( $target ) ? ( $target['id'] ?? 0 ) : $target; }, (array) ( $input['targets'] ?? array() ) ); $settings = $this->sanitizer->from_input( $input, $publishing ); if ( $settings instanceof WP_Error ) { return $settings; } $saved = $post_id > 0 ? $this->items->settings( $post_id )['targets'] : array(); $refused = array(); foreach ( array_diff( $settings['targets'], $saved ) as $target ) { $type = get_post_type( $target ); if ( false === $type || ! in_array( $type, $this->target_post_types(), true ) || ! current_user_can( 'edit_post', $target ) ) { $refused[] = $target; } } if ( array() !== $refused ) { return new WP_Error( 'yangra_conditional_invalid_scheduled_content', sprintf( /* translators: %s: post IDs, comma-separated. */ __( 'You can only show this content on pages and posts you can edit. Remove: %s', 'yangra-conditional' ), '#' . implode( ', #', $refused ) ), array( 'status' => 403, 'field' => 'targets', ) ); } /** * Filters an item's settings before they're saved. Pro adds its own fields. * * @param array $settings Cleaned settings. * @param array $input Submitted value. * @param int $post_id Item ID (0 for a new item). */ return apply_filters( 'yangra_conditional_scheduled_content_sanitize', $settings, $input, $post_id ); } /** * The settings for the editor, with what's read only. * * @param int $post_id Item ID. * @param array $settings Settings in the stored format. * @return array */ public function prepare( int $post_id, array $settings ): array { $purge = ScheduledContentPurges::last_purge( $post_id ); return array_merge( $this->input_from( $settings ), array( 'targets' => array_map( array( $this, 'target' ), $settings['targets'] ), 'showLabel' => $this->local( $settings['show_at'], $this->display_format() ), 'hideLabel' => $this->local( $settings['hide_at'], $this->display_format() ), 'status' => $this->status( $post_id, $settings ), 'lastPurge' => null === $purge ? null : array( 'timeLabel' => $this->local( $purge['time'], $this->display_format() ), 'caches' => $purge['caches'], 'ok' => $purge['ok'], 'late' => ScheduledContentPurges::is_late( $purge ), ), 'overlaps' => array_map( static function ( array $overlap ): array { return array( 'id' => $overlap['id'], 'title' => html_entity_decode( wp_strip_all_tags( $overlap['title'] ), ENT_QUOTES, 'UTF-8' ), 'editUrl' => (string) get_edit_post_link( $overlap['id'], 'raw' ), ); }, $this->items->overlapping_swaps( $post_id, $settings ) ), ) ); } /** * An item's status: "off" (not published), "incomplete" (published but * missing a target or a time), or its schedule's status. * * @param int $post_id Item ID. * @param array $settings Settings. * @return string */ public function status( int $post_id, array $settings ): string { if ( 'publish' !== get_post_status( $post_id ) ) { return 'off'; } if ( ! ScheduledContentSanitizer::is_set_up( $settings ) ) { return 'incomplete'; } return ScheduledElements::status( $settings ); } /** * Settings as the editor submits them. * * @param array $settings Settings in the stored format. * @return array */ private function input_from( array $settings ): array { return array( 'targets' => $settings['targets'], 'position' => $settings['position'], 'showAt' => $this->local( $settings['show_at'], ScheduleSanitizer::LOCAL_FORMAT ), 'hideAt' => $this->local( $settings['hide_at'], ScheduleSanitizer::LOCAL_FORMAT ), 'order' => $settings['order'], ); } /** * A target for the editor: its title with its type, and whether it's * built with a page builder. * * @param int $post_id Post ID. * @return array{id: int, title: string, builder: bool} */ public function target( int $post_id ): array { $builder = false; foreach ( self::PAGE_BUILDER_META as $key => $value ) { if ( (string) get_post_meta( $post_id, $key, true ) === $value ) { $builder = true; } } return array( 'id' => $post_id, 'title' => $this->title( $post_id ), 'builder' => $builder, ); } /** * A post's title with its type, e.g. "Pricing (Page)". * * @param int $post_id Post ID. * @return string */ private function title( int $post_id ): string { $post = get_post( $post_id ); if ( null === $post ) { return sprintf( /* translators: %d: post ID. */ __( '#%d (deleted)', 'yangra-conditional' ), $post_id ); } $title = html_entity_decode( wp_strip_all_tags( $post->post_title ), ENT_QUOTES, 'UTF-8' ); $type = get_post_type_object( $post->post_type ); return sprintf( /* translators: 1: post title, 2: post type, e.g. "Page". */ __( '%1$s (%2$s)', 'yangra-conditional' ), '' !== $title ? $title : sprintf( /* translators: %d: post ID. */ __( '#%d (no title)', 'yangra-conditional' ), $post_id ), null !== $type ? $type->labels->singular_name : $post->post_type ); } /** * Public post types an item can target. * * @return array */ private function target_post_types(): array { return array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment', ScheduledContent::POST_TYPE ) ) ); } /** * A timestamp in the site's timezone, or '' for none. * * @param int|null $timestamp UTC timestamp. * @param string $format Date format. * @return string */ private function local( ?int $timestamp, string $format ): string { return null === $timestamp ? '' : (string) wp_date( $format, $timestamp ); } /** * The site's date and time format, for reading. * * @return string */ private function display_format(): string { return get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); } }