sanitizer = $sanitizer; $this->registry = $registry; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'wp_after_insert_post', array( $this, 'on_save' ), 10, 2 ); } /** * Updates the index of a post after it's saved. Revisions and autosaves are skipped. * * @param int $post_id Post ID. * @param mixed $post Post. * @return void */ public function on_save( $post_id, $post ): void { if ( ! $post instanceof WP_Post || 'revision' === $post->post_type || wp_is_post_autosave( $post ) ) { return; } $this->update( (int) $post_id, $post->post_content ); } /** * Saves a post's index, or removes it when the post has nothing to index. * * @param int $post_id Post ID. * @param string $content Post content. * @return void */ public function update( int $post_id, string $content ): void { $index = $this->compute( $content ); if ( null === $index ) { delete_post_meta( $post_id, self::META_KEY ); } else { update_post_meta( $post_id, self::META_KEY, $index ); } } /** * What some post content contains, or null when there's nothing to index. * * @param string $content Post content. * @return array{blocks: int, menu_items: int, menu_list: array}>, schedules: int, shortcodes: int, countdowns: int, rules: array, pro: array}>}|null */ public function compute( string $content ): ?array { $found = false; foreach ( self::MARKERS as $marker ) { if ( false !== strpos( $content, $marker ) ) { $found = true; break; } } if ( ! $found ) { return null; } $index = array( 'blocks' => 0, 'menu_items' => 0, 'menu_list' => array(), 'schedules' => 0, 'shortcodes' => (int) preg_match_all( '/\[yangra_conditional(?:_[23])?(?=[\s\]\/])([^\]]*)/', $content, $tags ), 'countdowns' => (int) preg_match_all( '/\[yangra_conditional_countdown(?=[\s\]\/])/', $content ), 'rules' => $this->saved_rule_uses( $tags[1] ), 'pro' => array(), ); $this->count_blocks( parse_blocks( $content ), $index ); if ( 0 === $index['blocks'] && 0 === $index['schedules'] && 0 === $index['shortcodes'] && 0 === $index['countdowns'] ) { return null; } return $index; } /** * How many shortcodes use each saved rule, from the shortcodes' attribute * text. Attribute names match in any case, as in WordPress; values that * can't be a saved rule ID are left out. * * @param array $attributes Each shortcode's attribute text. * @return array Saved rule ID => shortcodes that use it. */ private function saved_rule_uses( array $attributes ): array { $uses = array(); foreach ( $attributes as $text ) { if ( 1 !== preg_match( '/(?:^|\s)rule\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\']+))/i', $text, $match ) ) { continue; } $id = trim( implode( '', array_slice( $match, 1 ) ) ); if ( 1 === preg_match( '/^[a-z0-9]{12}$/', $id ) ) { $uses[ $id ] = ( $uses[ $id ] ?? 0 ) + 1; } } return $uses; } /** * Adds blocks and their inner blocks to the counts. * * @param array> $blocks Parsed blocks. * @param array{blocks: int, menu_items: int, menu_list: array}>, schedules: int, shortcodes: int, countdowns: int, rules: array, pro: array}>} $index Counts so far. * @return void */ private function count_blocks( array $blocks, array &$index ): void { foreach ( $blocks as $block ) { $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( isset( $attrs[ BlockVisibility::ATTRIBUTE ] ) ) { $this->count_rule( (string) ( $block['blockName'] ?? '' ), $attrs, $index ); } if ( isset( $attrs['className'] ) && is_string( $attrs['className'] ) && 1 === preg_match( '/(?:^|\s)yc-schedule-[a-z0-9]{12}(?:\s|$)/', $attrs['className'] ) ) { ++$index['schedules']; } if ( self::COUNTDOWN_BLOCK === ( $block['blockName'] ?? '' ) ) { ++$index['countdowns']; } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $this->count_blocks( $block['innerBlocks'], $index ); } } } /** * Counts one block's rule, if it's on and has conditions. * * @param string $name Block name. * @param array $attrs Block attributes, including the saved rule. * @param array{blocks: int, menu_items: int, menu_list: array}>, schedules: int, shortcodes: int, countdowns: int, rules: array, pro: array}>} $index Counts so far. * @return void */ private function count_rule( string $name, array $attrs, array &$index ): void { $rule = $this->sanitizer->sanitize( $attrs[ BlockVisibility::ATTRIBUTE ] ?? null ); if ( empty( $rule['enabled'] ) ) { return; } $types = array(); foreach ( $rule['groups'] as $group ) { foreach ( $group['conditions'] as $condition ) { $types[] = (string) $condition['type']; } } if ( array() === $types ) { return; } $menu = in_array( $name, BlockVisibility::NAVIGATION_ITEMS, true ); ++$index['blocks']; if ( $menu ) { ++$index['menu_items']; $index['menu_list'][] = array( 'label' => $this->menu_item_label( $name, $attrs ), 'rule' => $rule, ); } $pro = array_values( array_unique( array_filter( $types, array( $this->registry, 'is_pro_type' ) ) ) ); if ( array() !== $pro ) { $index['pro'][] = array( 'menu' => $menu, 'types' => $pro, ); } } /** * A Navigation item's label as visitors see it, without markup. Home Link * and Login/out blocks have their own default text. * * @param string $name Block name. * @param array $attrs Block attributes. * @return string The label, or '' when the item has none. */ private function menu_item_label( string $name, array $attrs ): string { $label = isset( $attrs['label'] ) && is_string( $attrs['label'] ) ? trim( html_entity_decode( wp_strip_all_tags( $attrs['label'] ), ENT_QUOTES, 'UTF-8' ) ) : ''; if ( '' !== $label ) { return $label; } if ( 'core/home-link' === $name ) { return __( 'Home', 'yangra-conditional' ); } if ( 'core/loginout' === $name ) { return __( 'Log in / Log out', 'yangra-conditional' ); } return ''; } /** * Navigation items whose rule uses a Pro condition this build can't check, * in content that is shown (published or private). * * @return int */ public function menu_items_skipping_pro(): int { $count = 0; foreach ( $this->live_indexes() as $index ) { foreach ( (array) ( $index['pro'] ?? array() ) as $entry ) { if ( ! empty( $entry['menu'] ) && $this->skips( (array) ( $entry['types'] ?? array() ) ) ) { ++$count; } } } return $count; } /** * Blocks other than Navigation items whose rule uses a Pro condition this * build can't check, in shown content (published or private) that the * current user can edit. * * @return int */ public function blocks_skipping_pro(): int { $count = 0; foreach ( $this->live_posts() as $post_id => $index ) { foreach ( (array) ( $index['pro'] ?? array() ) as $entry ) { if ( ! empty( $entry['menu'] ) || ! $this->skips( (array) ( $entry['types'] ?? array() ) ) || ! current_user_can( 'edit_post', $post_id ) ) { continue; } ++$count; } } return $count; } /** * Countdown blocks and shortcodes in shown content (published or private) * that the current user can edit. * * @return int */ public function countdowns(): int { $count = 0; foreach ( $this->live_posts() as $post_id => $index ) { if ( ! empty( $index['countdowns'] ) && current_user_can( 'edit_post', $post_id ) ) { $count += (int) $index['countdowns']; } } return $count; } /** * Whether any of these Pro condition types can't be checked by this build. * * @param array $types Condition type IDs. * @return bool */ private function skips( array $types ): bool { foreach ( $types as $type ) { if ( null === $this->registry->get( (string) $type ) ) { return true; } } return false; } /** * Totals across the whole index, for the Dashboard's "Content index" card. * Counts come from shown content (published or private), like the other * cards. `blocks` leaves out Navigation items, which are in `menu_items`. * `unpublished` is how many drafts, pending and scheduled posts have * something indexed, which isn't counted until they're published. Trashed * posts are left out. * * @return array{posts: int, blocks: int, menu_items: int, schedules: int, shortcodes: int, unpublished: int} */ public function summary(): array { global $wpdb; $summary = array( 'posts' => 0, 'blocks' => 0, 'menu_items' => 0, 'schedules' => 0, 'shortcodes' => 0, 'unpublished' => 0, ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT p.post_status, m.meta_value FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE m.meta_key = %s AND p.post_status <> 'trash'", self::META_KEY ) ); foreach ( (array) $rows as $row ) { $index = maybe_unserialize( $row->meta_value ); if ( ! is_array( $index ) ) { continue; } if ( ! in_array( (string) $row->post_status, self::LIVE_STATUSES, true ) ) { ++$summary['unpublished']; continue; } $menu_items = (int) ( $index['menu_items'] ?? 0 ); ++$summary['posts']; $summary['blocks'] += max( 0, (int) ( $index['blocks'] ?? 0 ) - $menu_items ); $summary['menu_items'] += $menu_items; $summary['schedules'] += (int) ( $index['schedules'] ?? 0 ); $summary['shortcodes'] += (int) ( $index['shortcodes'] ?? 0 ); } return $summary; } /** * The index of every published or private post that has one. * * @return array> */ public function live_indexes(): array { return array_values( $this->live_posts() ); } /** * The index of every published or private post that has one, keyed by * post ID. Queried directly, because `post_type => any` leaves out * Navigation menus, templates and patterns (they're excluded from search). * * @return array> */ public function live_posts(): array { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT m.post_id, m.meta_value FROM {$wpdb->postmeta} m INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE m.meta_key = %s AND p.post_status IN ( %s, %s )", self::META_KEY, self::LIVE_STATUSES[0], self::LIVE_STATUSES[1] ) ); $indexes = array(); foreach ( (array) $rows as $row ) { $index = maybe_unserialize( $row->meta_value ); if ( is_array( $index ) ) { $indexes[ (int) $row->post_id ] = $index; } } return $indexes; } }