sanitizer = $sanitizer; } /** * Registers hooks. * * @return void */ public function register(): void { add_filter( 'wp_insert_post_data', array( $this, 'copy_rules' ), 10, 4 ); add_action( 'wp_insert_post', array( $this, 'flag_post' ), 10, 3 ); add_action( 'rest_api_init', array( $this, 'register_rest_field' ) ); } /** * Copies classic menu rules into a new `wp_navigation` post's blocks. * * @param mixed $data Slashed post data about to be saved. * @param mixed $postarr Raw post array. * @param mixed $unsanitized_postarr Unsanitized post array. * @param bool $update Whether an existing post is being updated. * @return mixed */ public function copy_rules( $data, $postarr = array(), $unsanitized_postarr = array(), $update = false ) { $this->pending_notice = null; if ( $update || ! is_array( $data ) || 'wp_navigation' !== ( $data['post_type'] ?? '' ) ) { return $data; } $title = isset( $data['post_title'] ) ? (string) wp_unslash( $data['post_title'] ) : ''; $content = isset( $data['post_content'] ) ? (string) wp_unslash( $data['post_content'] ) : ''; $slug = isset( $data['post_name'] ) ? (string) $data['post_name'] : ''; $blocks = parse_blocks( $content ); if ( '' === $title || ! $this->is_converted_menu( $blocks ) ) { return $data; } $unmatched = null; foreach ( $this->candidates( $title, $slug ) as $menu ) { $tree = $this->classic_tree( $menu->term_id ); $has_rules = $this->has_rules( $tree ); if ( ! $this->matches( $tree, $blocks ) ) { if ( $has_rules && null === $unmatched ) { $unmatched = $menu->name; } continue; } // This is the menu it came from. if ( $has_rules ) { $data['post_content'] = wp_slash( serialize_blocks( $this->with_rules( $tree, $blocks ) ) ); } return $data; } $this->pending_notice = $unmatched; return $data; } /** * Saves the "rules not copied" flag on the post just created. * * @param int $post_id Post ID. * @param mixed $post Post. * @param bool $update Whether an existing post was updated. * @return void */ public function flag_post( $post_id, $post, $update ): void { if ( null === $this->pending_notice || $update || ! $post instanceof WP_Post || 'wp_navigation' !== $post->post_type ) { return; } update_post_meta( (int) $post_id, self::NOTICE_META, $this->pending_notice ); $this->pending_notice = null; } /** * Exposes the flag to the editor. Setting it to an empty string clears it. * * @return void */ public function register_rest_field(): void { register_rest_field( 'wp_navigation', self::REST_FIELD, array( 'get_callback' => static function ( $post ) { return (string) get_post_meta( (int) $post['id'], self::NOTICE_META, true ); }, 'update_callback' => static function ( $value, $post ) { if ( $post instanceof WP_Post && '' === (string) $value ) { delete_post_meta( $post->ID, self::NOTICE_META ); } return true; }, 'schema' => array( 'description' => __( 'Name of the classic menu whose visibility rules could not be copied to this menu.', 'yangra-conditional' ), 'type' => 'string', 'context' => array( 'edit' ), ), ) ); } /** * Whether the blocks look like a converted classic menu: at least one item, * and only Navigation Link and Submenu blocks. New empty menus, Page List * fallbacks and hand-built menus are left alone. * * @param array> $blocks Parsed blocks. * @return bool */ private function is_converted_menu( array $blocks ): bool { $items = $this->items( $blocks ); if ( array() === $items ) { return false; } foreach ( $items as $block ) { if ( ! in_array( $block['blockName'], self::CONVERTED_BLOCKS, true ) ) { return false; } } return true; } /** * Classic menus with this name. Two can share a name, so each is tried; the * one with the same slug first (the fallback copies the slug too). * * @param string $title Navigation post title. * @param string $slug Navigation post slug. * @return array */ private function candidates( string $title, string $slug ): array { $title = $this->normalize( $title ); $candidates = array(); foreach ( wp_get_nav_menus() as $menu ) { if ( $this->normalize( $menu->name ) === $title ) { $candidates[] = $menu; } } usort( $candidates, static function ( WP_Term $a, WP_Term $b ) use ( $slug ): int { return (int) ( $b->slug === $slug ) - (int) ( $a->slug === $slug ); } ); return $candidates; } /** * A classic menu's items as a tree, built the way both converters build it: * items in menu order, grouped by parent, starting from the top level. Items * whose parent isn't in the menu are left out, as the converters do. * * @param int $menu_id Menu term ID. * @return array}> */ private function classic_tree( int $menu_id ): array { $items = wp_get_nav_menu_items( $menu_id, array( 'update_post_term_cache' => false ) ); if ( ! is_array( $items ) ) { return array(); } $by_parent = array(); foreach ( $items as $item ) { $by_parent[ (int) $item->menu_item_parent ][] = $item; } return $this->branch( 0, $by_parent, array() ); } /** * One level of the classic menu tree. * * @param int $parent_id Parent item ID, 0 for the top level. * @param array> $by_parent Items grouped by parent ID. * @param array $seen Item IDs already in the tree, to stop loops. * @return array}> */ private function branch( int $parent_id, array $by_parent, array $seen ): array { $nodes = array(); foreach ( $by_parent[ $parent_id ] ?? array() as $item ) { $id = (int) $item->ID; if ( isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; $nodes[] = array( 'item' => $item, 'children' => $this->branch( $id, $by_parent, $seen ), ); } return $nodes; } /** * Whether any item in the tree has a saved rule. * * @param array}> $nodes Tree. * @return bool */ private function has_rules( array $nodes ): bool { foreach ( $nodes as $node ) { if ( null !== $this->rule( $node['item'] ) || $this->has_rules( $node['children'] ) ) { return true; } } return false; } /** * Whether the blocks have the same shape as the classic tree, with the same * label and URL at every position. * * @param array}> $nodes Classic tree. * @param array> $blocks Parsed blocks at the same level. * @return bool */ private function matches( array $nodes, array $blocks ): bool { $items = array_values( $this->items( $blocks ) ); if ( count( $items ) !== count( $nodes ) ) { return false; } foreach ( $nodes as $index => $node ) { $block = $items[ $index ]; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $label = $this->normalize( (string) ( $attrs['label'] ?? '' ) ); $url = $this->normalize( (string) ( $attrs['url'] ?? '' ) ); $item = $node['item']; if ( ! in_array( $block['blockName'], self::CONVERTED_BLOCKS, true ) || ! in_array( $label, $this->labels( $item ), true ) || $this->normalize( (string) $item->url ) !== $url || ! $this->matches( $node['children'], (array) ( $block['innerBlocks'] ?? array() ) ) ) { return false; } } return true; } /** * The labels a converter can give an item: the fallback uses the raw title, * the editor the rendered one (through `the_title`, which can change quotes). * * @param object $item Menu item. * @return array */ private function labels( $item ): array { $title = (string) $item->title; return array( $this->normalize( $title ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter: the editor's labels are rendered through it. $this->normalize( (string) apply_filters( 'the_title', $title, (int) $item->ID ) ), ); } /** * Returns the blocks with each classic item's rule copied to its block. * Only call with blocks that match(). * * @param array}> $nodes Classic tree. * @param array> $blocks Parsed blocks at the same level. * @return array> */ private function with_rules( array $nodes, array $blocks ): array { $position = 0; foreach ( $blocks as $key => $block ) { if ( empty( $block['blockName'] ) ) { continue; } $node = $nodes[ $position ]; ++$position; $rule = $this->rule( $node['item'] ); if ( null !== $rule ) { $blocks[ $key ]['attrs'] = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $blocks[ $key ]['attrs'][ BlockVisibility::ATTRIBUTE ] = $rule; } $blocks[ $key ]['innerBlocks'] = $this->with_rules( $node['children'], (array) ( $block['innerBlocks'] ?? array() ) ); } return $blocks; } /** * A classic item's saved rule, in the current format, or null when it has none. * * @param object $item Menu item. * @return array|null */ private function rule( $item ): ?array { $saved = get_post_meta( (int) $item->ID, ClassicMenuFilter::META_KEY, true ); return is_array( $saved ) && array() !== $saved ? $this->sanitizer->sanitize( $saved ) : null; } /** * Blocks that aren't whitespace between blocks. * * @param array> $blocks Parsed blocks. * @return array> */ private function items( array $blocks ): array { return array_filter( $blocks, static function ( $block ) { return ! empty( $block['blockName'] ); } ); } /** * Text as it reads, for comparing labels, names and URLs. * * @param string $text Text, possibly with tags or entities. * @return string */ private function normalize( string $text ): string { return trim( html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } }