schedules = $schedules; $this->sanitizer = $sanitizer; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'wp_after_insert_post', array( $this, 'on_save_post' ), 10, 2 ); add_action( 'deleted_post', array( $this, 'on_delete_post' ) ); add_filter( 'widget_update_callback', array( $this, 'on_save_widget' ), 30, 4 ); add_action( 'delete_widget', array( $this, 'on_delete_widget' ) ); } /** * Syncs a post's block schedules after it's saved, or marks them "not * found" when it's trashed. * * @param int|string $post_id Post ID. * @param mixed $post Post. * @return void */ public function on_save_post( $post_id, $post ): void { if ( ! $post instanceof WP_Post || wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return; } $location = 'post:' . (int) $post_id; if ( 'trash' === $post->post_status ) { $this->mark_not_found( $location ); return; } $this->sync( $location, (string) $post->post_content, $post->post_title ); } /** * Marks a deleted post's block schedules "not found". * * @param int|string $post_id Post ID. * @return void */ public function on_delete_post( $post_id ): void { $this->mark_not_found( 'post:' . (int) $post_id ); } /** * Syncs a block widget's schedules when it's saved. Its settings are passed * on unchanged. * * @param mixed $instance Settings about to be saved, or false. * @param mixed $new_instance Submitted settings. * @param mixed $old_instance Saved settings. * @param mixed $widget Widget. * @return mixed */ public function on_save_widget( $instance, $new_instance, $old_instance, $widget ) { if ( is_array( $instance ) && $widget instanceof \WP_Widget && 'block' === $widget->id_base && is_string( $widget->id ) && '' !== $widget->id && ! $this->is_preview_request() ) { $this->sync( 'widget:' . $widget->id, (string) ( $instance['content'] ?? '' ), '' ); } return $instance; } /** * Whether this is the widget editor's preview of an unsaved widget * (`/wp/v2/widget-types/{type}/encode`), which runs the update filter with a * temporary widget number. Nothing is saved there, so nothing is synced. * * @return bool */ private function is_preview_request(): bool { if ( ! wp_is_serving_rest_request() ) { return false; } $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? (string) $GLOBALS['wp']->query_vars['rest_route'] : ''; return 1 === preg_match( '#^/wp/v2/widget-types/[^/]+/encode#', $route ); } /** * Marks a deleted block widget's schedules "not found". * * @param mixed $widget_id Widget ID, e.g. "block-3". * @return void */ public function on_delete_widget( $widget_id ): void { if ( is_string( $widget_id ) && '' !== $widget_id ) { $this->mark_not_found( 'widget:' . $widget_id ); } } /** * Brings the list in line with the block schedules in some content. * * @param string $location Where the content is: "post:{id}" or "widget:{id}". * @param string $content Block markup. * @param string $title Title of the post, for new schedules' names; '' for a widget. * @return void */ public function sync( string $location, string $content, string $title ): void { $existing = array(); foreach ( $this->schedules->all() as $schedule ) { if ( 'block' === $schedule['source'] && ( $schedule['location'] ?? '' ) === $location ) { $existing[ $schedule['id'] ] = $schedule; } } // Most content has no block schedules: skip parsing it. if ( array() === $existing && false === strpos( $content, BlockVisibility::SCHEDULE_ATTRIBUTE ) ) { return; } $found = array(); $this->collect( parse_blocks( $content ), $found ); foreach ( $found as $id => $block ) { $schedule = $this->schedule( $id, $block, $location, $title, $existing[ $id ] ?? $this->schedules->get( $id ) ); if ( null !== $schedule ) { $this->schedules->save( $schedule ); unset( $existing[ $id ] ); } } foreach ( array_keys( $existing ) as $id ) { $this->schedules->delete( (string) $id ); } } /** * Marks the block schedules from some content "not found". * * @param string $location Where the content was. * @return void */ public function mark_not_found( string $location ): void { foreach ( $this->schedules->all() as $schedule ) { if ( 'block' === $schedule['source'] && ( $schedule['location'] ?? '' ) === $location && empty( $schedule['not_found'] ) ) { $schedule['not_found'] = true; $this->schedules->save( $schedule ); } } } /** * Finds blocks with a schedule, in parsed blocks and their inner blocks. * A block counts only when it has both the attribute and its class. * * @param array> $blocks Parsed blocks. * @param array> $found Blocks found so far, by schedule ID. * @return void */ private function collect( array $blocks, array &$found ): void { foreach ( $blocks as $block ) { $attribute = $block['attrs'][ BlockVisibility::SCHEDULE_ATTRIBUTE ] ?? null; $id = is_array( $attribute ) && isset( $attribute['id'] ) && is_string( $attribute['id'] ) ? $attribute['id'] : ''; $classes = isset( $block['attrs']['className'] ) && is_string( $block['attrs']['className'] ) ? preg_split( '/\s+/', $block['attrs']['className'] ) : array(); if ( 1 === preg_match( '/^[a-z0-9]{12}$/', $id ) && in_array( self::CLASS_PREFIX . $id, (array) $classes, true ) && ! isset( $found[ $id ] ) ) { $found[ $id ] = $block; } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $this->collect( $block['innerBlocks'], $found ); } } } /** * The list entry for a block's schedule, or null when its times aren't * valid (no time, or the hide time isn't after the show time). * * @param string $id Schedule ID. * @param array $block Parsed block. * @param string $location Where the block is. * @param string $title Title of the post the block is in, or ''. * @param array|null $current The schedule already in the list, if any. * @return array|null */ private function schedule( string $id, array $block, string $location, string $title, ?array $current ): ?array { $attribute = (array) $block['attrs'][ BlockVisibility::SCHEDULE_ATTRIBUTE ]; $show_at = $this->sanitizer->local_time( $attribute['showAt'] ?? '' ); $hide_at = $this->sanitizer->local_time( $attribute['hideAt'] ?? '' ); if ( false === $show_at || false === $hide_at || ( null === $show_at && null === $hide_at ) ) { return null; } if ( null !== $show_at && null !== $hide_at && $hide_at <= $show_at ) { return null; } $current = ( null !== $current && 'block' === $current['source'] ) ? $current : array(); return array_merge( $current, array( 'id' => $id, 'name' => '' !== ( $current['name'] ?? '' ) ? $current['name'] : $this->name( (string) $block['blockName'], $title ), 'targets' => array( '.' . self::CLASS_PREFIX . $id ), 'show_at' => $show_at, 'hide_at' => $hide_at, 'scope' => 'site', 'posts' => array(), 'enabled' => $current['enabled'] ?? true, 'source' => 'block', 'location' => $location, 'not_found' => false, ) ); } /** * A new block schedule's name, e.g. "Cover block in “Black Friday”". * * @param string $block_name Block name, e.g. "core/cover". * @param string $title Title of the post the block is in, or '' for a widget. * @return string */ private function name( string $block_name, string $title ): string { $type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); $label = null !== $type && '' !== (string) $type->title ? (string) $type->title : $block_name; $title = trim( wp_strip_all_tags( $title ) ); if ( '' === $title ) { /* translators: %s: block name, e.g. "Cover". */ return sprintf( __( '%s block in a widget', 'yangra-conditional' ), $label ); } /* translators: 1: block name, e.g. "Cover", 2: post title. */ return sprintf( __( '%1$s block in “%2$s”', 'yangra-conditional' ), $label, $title ); } }