From bf4b7f2b0137ec21803ba477c2d34de5029ad047 Mon Sep 17 00:00:00 2001 From: Sagar Deshmukh Date: Tue, 4 Jun 2024 11:42:19 +0530 Subject: [PATCH] PLANET-7527 Move blocks report into master theme Ref. https://jira.greenpeace.org/browse/PLANET-7527 --- src/BlockReportSearch/Block/BlockUsage.php | 278 ++++++++ src/BlockReportSearch/Block/BlockUsageApi.php | 95 +++ .../Block/BlockUsageTable.php | 638 ++++++++++++++++++ src/BlockReportSearch/Block/Query.php | 22 + .../Block/Query/Parameters.php | 189 ++++++ src/BlockReportSearch/Block/Sql/Like.php | 48 ++ src/BlockReportSearch/Block/Sql/Regex.php | 48 ++ src/BlockReportSearch/Block/Sql/SqlQuery.php | 116 ++++ src/BlockReportSearch/BlockSearch.php | 47 ++ .../Pattern/ContentStructure.php | 181 +++++ src/BlockReportSearch/Pattern/PatternData.php | 125 ++++ .../Pattern/PatternUsage.php | 202 ++++++ .../Pattern/PatternUsageApi.php | 100 +++ .../Pattern/PatternUsageTable.php | 405 +++++++++++ .../Pattern/Query/Parameters.php | 153 +++++ src/BlockReportSearch/PatternSearch.php | 170 +++++ src/BlockReportSearch/RowActions.php | 76 +++ src/Commands/DuplicatedPostmeta.php | 86 +++ .../Menu/BlocksReportController.php | 36 + .../Menu/BlocksUsageController.php | 227 +++++++ src/Controllers/Menu/ClassicBlocksUsage.php | 86 +++ .../Menu/PostmetaCheckController.php | 77 +++ .../Menu/ReusableBlocksController.php | 41 ++ src/Loader.php | 5 + src/Patterns/BlankPage.php | 45 ++ src/Patterns/BlockPattern.php | 1 + templates/duplicate-postmeta-report.twig | 56 ++ 27 files changed, 3553 insertions(+) create mode 100644 src/BlockReportSearch/Block/BlockUsage.php create mode 100644 src/BlockReportSearch/Block/BlockUsageApi.php create mode 100644 src/BlockReportSearch/Block/BlockUsageTable.php create mode 100644 src/BlockReportSearch/Block/Query.php create mode 100644 src/BlockReportSearch/Block/Query/Parameters.php create mode 100644 src/BlockReportSearch/Block/Sql/Like.php create mode 100644 src/BlockReportSearch/Block/Sql/Regex.php create mode 100644 src/BlockReportSearch/Block/Sql/SqlQuery.php create mode 100644 src/BlockReportSearch/BlockSearch.php create mode 100644 src/BlockReportSearch/Pattern/ContentStructure.php create mode 100644 src/BlockReportSearch/Pattern/PatternData.php create mode 100644 src/BlockReportSearch/Pattern/PatternUsage.php create mode 100644 src/BlockReportSearch/Pattern/PatternUsageApi.php create mode 100644 src/BlockReportSearch/Pattern/PatternUsageTable.php create mode 100644 src/BlockReportSearch/Pattern/Query/Parameters.php create mode 100644 src/BlockReportSearch/PatternSearch.php create mode 100644 src/BlockReportSearch/RowActions.php create mode 100644 src/Commands/DuplicatedPostmeta.php create mode 100644 src/Controllers/Menu/BlocksReportController.php create mode 100644 src/Controllers/Menu/BlocksUsageController.php create mode 100644 src/Controllers/Menu/ClassicBlocksUsage.php create mode 100644 src/Controllers/Menu/PostmetaCheckController.php create mode 100644 src/Controllers/Menu/ReusableBlocksController.php create mode 100644 src/Patterns/BlankPage.php create mode 100644 templates/duplicate-postmeta-report.twig diff --git a/src/BlockReportSearch/Block/BlockUsage.php b/src/BlockReportSearch/Block/BlockUsage.php new file mode 100644 index 0000000000..682b1a9000 --- /dev/null +++ b/src/BlockReportSearch/Block/BlockUsage.php @@ -0,0 +1,278 @@ +search = $search ?? new BlockSearch(); + $this->parser = $parser ?? new WP_Block_Parser(); + } + + /** + * @param Parameters $params Query parameters. + * @return array + */ + public function get_blocks( Parameters $params ): array { + $this->posts_ids = $this->search->get_posts( $params ); + + return $this->get_filtered_blocks( $this->posts_ids, $params ); + } + + /** + * @param Parameters $params Query parameters. + * @return int[] + */ + public function get_posts( Parameters $params ): array { + $block_list = $this->get_blocks( $this->posts_ids, $params ); + + return array_unique( array_column( $block_list, 'post_id' ) ); + } + + /** + * @param int[] $posts_ids Posts IDs. + * @param Parameters $params Query parameters. + * @return array + */ + private function get_filtered_blocks( $posts_ids, Parameters $params ) { + $this->fetch_blocks( $posts_ids, $params ); + $this->filter_blocks( $params ); + $this->sort_blocks( $params->order() ); + + return $this->blocks; + } + + /** + * @param int[] $posts_ids Posts IDs. + * @param Parameters $params Query parameters. + */ + private function fetch_blocks( array $posts_ids, Parameters $params ): void { + $posts_args = [ + 'include' => $posts_ids, + 'orderby' => empty( $params->order() ) ? null : array_fill_keys( $params->order(), 'ASC' ), + 'post_status' => $params->post_status(), + 'post_type' => $params->post_type(), + ]; + + $this->posts = get_posts( $posts_args ) ?? []; + + $block_listblock_list = []; + foreach ( $this->posts as $post ) { + $block_listblock_list = array_merge( $block_listblock_list, $this->parse_post( $post ) ); + } + $this->blocks = $block_listblock_list; + } + + /** + * Filter parsed search items + * + * @param Parameters $params Query parameters. + * @return array + */ + private function filter_blocks( Parameters $params ): void { + if ( + empty( $params->namespace() ) + && empty( $params->name() ) + && empty( $params->content() ) + ) { + return; + } + + $filtered = $this->blocks; + + $text = $params->content(); + $filters = [ + 'block_ns' => $params->namespace(), + 'block_type' => $params->name(), + 'local_name' => false !== strpos( $params->name(), '/' ) + ? explode( '/', $params->name() )[1] + : $params->name(), + ]; + + if ( ! empty( $filters['block_type'] ) ) { + $filtered = array_filter( + $filtered, + function ( $i ) use ( $filters ) { + return $i['block_type'] === $filters['block_type'] + || $i['local_name'] === $filters['local_name']; + } + ); + } elseif ( ! empty( $filters['block_ns'] ) ) { + $filtered = array_filter( + $filtered, + function ( $i ) use ( $filters ) { + return $i['block_ns'] === $filters['block_ns']; + } + ); + } + + if ( ! empty( $text ) ) { + $filtered = array_filter( + $filtered, + function ( $i ) use ( $text ) { + return strpos( $i['block_type'], $text ) !== false + //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + || strpos( serialize( $i['block_attrs'] ), $text ) !== false; + } + ); + } + + $this->blocks = $filtered; + } + + /** + * Sort parsed blocks + * + * @param null|array $sort Sort dimensions. + * @return array + */ + private function sort_blocks( ?array $sort = [] ): void { + if ( empty( $sort ) ) { + return; + } + + $args = []; + $block_list = $this->blocks; + foreach ( $sort as $name ) { + $args[] = array_column( $block_list, $name ); + $args[] = SORT_NATURAL; + } + $args[] = &$block_list; + + array_multisort( ...$args ); + + $this->blocks = $block_list; + } + + /** + * Parse posts content to blocks. + * + * @param object $post WP_Post. + * @return array[] + */ + private function parse_post( $post ): array { + $output = $this->parser->parse( $post->post_content ); + + $block_list = array_filter( + $output, + function ( $block ) { + return ! empty( $block['blockName'] ); + } + ); + + $items = []; + while ( ! empty( $block_list ) ) { + $block = array_shift( $block_list ); + $items[] = $this->format_block_data( $block, $post ); + + if ( ! empty( $block['innerBlocks'] ) ) { + $block_list = array_merge( $block_list, $block['innerBlocks'] ); + } + } + + return $items; + } + + /** + * Format block information. + * + * @param array $block A block. + * @param object $post WP_Post. + * @return array[] + */ + private function format_block_data( array $block, $post ): array { + $type = $block['blockName']; + $attrs = $block['attrs'] ?? []; + $has_ns = strpos( $type, '/' ) !== false; + + [ $namespace, $local_name ] = $has_ns ? explode( '/', $type ) : [ 'core', $type ]; + + $classes = empty( $attrs['className'] ) ? [] : explode( ' ', $attrs['className'] ); + $styles = array_filter( + array_map( + function ( $c ): ?string { + return 'is-style-' === substr( $c, 0, 9 ) ? substr( $c, 9 ) : null; + }, + $classes + ) + ); + + return [ + 'post_id' => $post->ID, + 'post_title' => $post->post_title + ? $post->post_title : __( '(no title)', 'planet4-blocks-backend' ), + 'post_status' => $post->post_status, + 'post_type' => $post->post_type, + 'post_date' => $post->post_date, + 'post_modified' => $post->post_modified, + 'post_status' => $post->post_status, + 'guid' => $post->guid, + 'block_ns' => $namespace, + 'block_type' => $type, + 'local_name' => $local_name, + 'block_attrs' => $attrs, + 'block_styles' => $styles, + ]; + } + + /** + * Block count in search result. + * + * @return int + */ + public function block_count(): int { + return count( $this->blocks ); + } + + /** + * Post count in search result. + * + * @return int + */ + public function post_count(): int { + return count( $this->posts_ids ); + } +} diff --git a/src/BlockReportSearch/Block/BlockUsageApi.php b/src/BlockReportSearch/Block/BlockUsageApi.php new file mode 100644 index 0000000000..713ac4e094 --- /dev/null +++ b/src/BlockReportSearch/Block/BlockUsageApi.php @@ -0,0 +1,95 @@ +usage = new BlockUsage(); + $this->params = ( new Parameters() ) + ->with_post_status( self::DEFAULT_POST_STATUS ) + ->with_post_type( + \get_post_types( + [ + 'public' => true, + 'exclude_from_search' => false, + ] + ) + ); + } + + /** + * Count blocks by type and style + * + * If style is not specified, an empty key 'n/a' is used. + */ + public function get_count(): array { + if ( null === $this->items ) { + $this->fetch_items(); + } + + $types = array_unique( + array_column( $this->items, 'block_type' ) + ); + $blocks = array_fill_keys( + $types, + [ + 'total' => 0, + 'styles' => [], + ] + ); + ksort( $blocks ); + + foreach ( $this->items as $item ) { + $styles = empty( $item['block_styles'] ) ? [ 'n/a' ] : $item['block_styles']; + foreach ( $styles as $style ) { + $type = $item['block_type']; + if ( ! isset( $blocks[ $type ]['styles'][ $style ] ) ) { + $blocks[ $type ]['styles'][ $style ] = 0; + } + $blocks[ $type ]['styles'][ $style ]++; + $blocks[ $type ]['total']++; + } + ksort( $blocks[ $type ]['styles'] ); + } + + return $blocks; + } + + /** + * Fetch parsed blocks + */ + private function fetch_items(): void { + $this->items = $this->usage->get_blocks( $this->params ); + } +} diff --git a/src/BlockReportSearch/Block/BlockUsageTable.php b/src/BlockReportSearch/Block/BlockUsageTable.php new file mode 100644 index 0000000000..ce9e3592c5 --- /dev/null +++ b/src/BlockReportSearch/Block/BlockUsageTable.php @@ -0,0 +1,638 @@ + title. + */ + private $columns = null; + + /** + * @var string|null Latest row content displayed. + */ + private $latest_row = null; + + /** + * @var string[]|null Blocks namespaces. + */ + private $blocks_ns = null; + + /** + * @var string[]|null Blocks types. + */ + private $blocks_types = null; + + /** + * @var string[]|null Blocks registered. + */ + private $blocks_registered = null; + + /** + * @var string[]|null Blocks allowed. + */ + private $blocks_allowed = null; + + /** + * @var ?string Special filter. + */ + private $special = null; + + /** + * @param array $args Args. + * @throws InvalidArgumentException Throws on missing parameter. + * @see WP_List_Table::__construct() + */ + public function __construct( $args = [] ) { + $args['plural'] = self::PLURAL; + parent::__construct( $args ); + + $this->block_usage = $args['block_usage'] ?? null; + $this->block_registry = $args['block_registry'] ?? null; + + if ( ! ( $this->block_usage instanceof BlockUsage ) ) { + throw new InvalidArgumentException( + 'Table requires a BlockUsage instance.' + ); + } + if ( ! ( $this->block_registry instanceof WP_Block_Type_Registry ) ) { + throw new InvalidArgumentException( + 'Table requires a WP_Block_Type_Registry instance.' + ); + } + } + + /** + * Prepares table data. + * + * @param Parameters $search_params Search parameters. + * @param string $group_by Grouping dimension. + * @param ?string $special Unregistered blocks only. + */ + public function prepare_items( + ?Parameters $search_params = null, + ?string $group_by = null, + ?string $special = null + ): void { + if ( in_array( $group_by, $this->allowed_groups, true ) ) { + $this->group_by = $group_by; + } + + $this->search_params = $search_params + ->with_post_status( self::DEFAULT_POST_STATUS ) + ->with_post_type( + array_filter( + \get_post_types( [ 'show_in_rest' => true ] ), + fn ( $t ) => \post_type_supports( $t, 'editor' ) + ) + ) + ->with_order( array_merge( [ $this->group_by ], $this->sort_by ) ); + + $items = $this->block_usage->get_blocks( $this->search_params ); + + $this->special = $special; + if ( 'unregistered' === $this->special ) { + $items = $this->filter_for_unregistered( $items ); + } + if ( 'unallowed' === $this->special ) { + $items = $this->filter_for_unallowed( $items ); + } + + // Pagination handling. + $total_items = count( $items ); + $per_page = 50; + $current_page = $this->get_pagenum(); + $this->items = array_slice( $items, ( ( $current_page - 1 ) * $per_page ), $per_page ); + $this->set_pagination_args( + [ + 'total_items' => $total_items, + 'per_page' => $per_page, + 'total_pages' => ceil( $total_items / $per_page ), + ] + ); + + $this->set_block_filters(); + $this->_column_headers = $this->get_column_headers(); + } + + /** + * Filter items to keep unregistered blocks only. + * + * @param array $items Blocks not registered. + */ + private function filter_for_unregistered( array $items ): array { + $this->set_registered_blocks(); + return array_filter( + $items, + fn ( $i ) => ! in_array( $i['block_type'], $this->blocks_registered, true ) && 'core-embed' !== $i['block_ns'] + ); + } + + /** + * Filter items to keep unallowed blocks only. + * + * @param array $items Blocks not registered. + */ + private function filter_for_unallowed( array $items ): array { + $this->set_allowed_blocks(); + return array_filter( + $items, + fn ( $i ) => ! in_array( $i['block_type'], $this->blocks_allowed, true ) + ); + } + + /** + * Set dropdown filters content. + */ + private function set_block_filters(): void { + $this->set_registered_blocks(); + $this->set_allowed_blocks(); + $this->blocks_types = array_unique( + array_merge( + $this->blocks_registered, + $this->blocks_allowed + ) + ); + + $namespaces = array_filter( + array_unique( + array_map( + static function ( string $name ) { + return explode( '/', $name )[0] ?? null; + }, + $this->blocks_types + ) + ) + ); + sort( $namespaces ); + $this->blocks_ns = $namespaces; + } + + /** + * Set the registered blocks list. + */ + private function set_registered_blocks() { + $names = array_keys( + $this->block_registry->get_all_registered() + ); + sort( $names ); + $this->blocks_registered = $names; + } + + /** + * Set the allowed blocks list. + */ + private function set_allowed_blocks() { + $post_types = array_filter( + get_post_types( [ 'show_in_rest' => true ] ), + fn ( $t ) => post_type_supports( $t, 'editor' ) + ); + + $allowed = []; + foreach ( $post_types as $type ) { + $context = new \WP_Block_Editor_Context( + [ 'post' => (object) [ 'post_type' => $type ] ] + ); + $on_type = get_allowed_block_types( $context ); + if ( ! is_array( $on_type ) ) { + $on_type = $on_type ? $this->blocks_registered : []; + } + $allowed = array_merge( $allowed, array_values( $on_type ) ); + } + + $allowed = array_unique( $allowed ); + sort( $allowed ); + $this->blocks_allowed = $allowed; + } + + /** + * Columns list for table. + */ + public function get_columns() { + if ( null !== $this->columns ) { + return $this->columns; + } + + $default_columns = [ + 'post_title' => 'Title', + 'block_type' => 'Block', + 'block_styles' => 'Style', + 'block_attrs' => 'Attributes', + 'post_date' => 'Created', + 'post_modified' => 'Modified', + 'post_id' => 'ID', + 'post_status' => 'Status', + ]; + + $this->columns = array_merge( + [ $this->group_by => $default_columns[ $this->group_by ] ], + $default_columns + ); + + return $this->columns; + } + + /** + * All, hidden and sortable columns. + */ + private function get_column_headers() { + return [ + $this->get_columns(), + [], + [ 'post_title', 'post_date', 'post_modified' ], + ]; + } + + /** + * Available grouping as views. + */ + protected function get_views() { + $link_tpl = '%s'; + $active_link_tpl = '%s'; + return [ + 'block_type' => sprintf( + 'block_type' === $this->group_by ? $active_link_tpl : $link_tpl, + add_query_arg( 'group', 'block_type' ), + 'Group by block name' + ), + 'post_title' => sprintf( + 'post_title' === $this->group_by ? $active_link_tpl : $link_tpl, + add_query_arg( 'group', 'post_title' ), + 'Group by post title' + ), + 'post_id' => sprintf( + 'post_id' === $this->group_by ? $active_link_tpl : $link_tpl, + add_query_arg( 'group', 'post_id' ), + 'Group by post ID' + ), + ]; + } + + + /** + * Displays the list of views available on this table. + */ + public function views() { + parent::views(); + + $link_tpl = '%s'; + $active_link_tpl = '%s'; + $unique_views = [ + 'unregistered' => sprintf( + 'unregistered' === $this->special ? $active_link_tpl : $link_tpl, + 'unregistered' === $this->special + ? self::url() + : add_query_arg( [ 'unregistered' => '' ], self::url() ), + 'Not registered' + ), + 'unallowed' => sprintf( + 'unallowed' === $this->special ? $active_link_tpl : $link_tpl, + 'unallowed' === $this->special + ? self::url() + : add_query_arg( [ 'unallowed' => '' ], self::url() ), + 'Not allowed' + ), + ]; + + $views = []; + echo '
'; + } + + /** + * Select blocks namespaces. + */ + private function blockns_dropdown() { + sort( $this->blocks_ns ); + $filter = $this->search_params->namespace() ?? null; + + echo ''; + } + + /** + * Select blocks types. + */ + private function blocktype_dropdown() { + sort( $this->blocks_types ); + $filter = $this->search_params->name() ?? null; + + echo ''; + + echo ""; + } + + /** + * Add filters to table. + * + * @param string $which Tablenav identifier. + */ + protected function extra_tablenav( $which ) { + echo '
'; + $this->blockns_dropdown(); + $this->blocktype_dropdown(); + submit_button( + __( 'Filter', 'planet4-blocks-backend' ), + '', + 'filter_action', + false, + [ 'id' => 'block-query-submit' ] + ); + echo '
'; + } + + /** + * Add pagination information to table. + * + * @param string $which Tablenav identifier. + */ + protected function pagination( $which ) { + echo esc_html( parent::pagination( 'top' ) ); + } + + /** + * Default column value representation. + * + * @param array $item Item. + * @param string $column_name Column name. + * + * @return mixed + */ + public function column_default( $item, $column_name ) { + return $item[ $column_name ] ?? ''; + } + + /** + * Block option display. + * + * @param array $item Item. + * @return string + */ + public function column_block_attrs( $item ): string { + $content = $item['block_attrs'] ?? null; + if ( empty( $content ) ) { + return ''; + } + + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + $content = print_r( $content, true ); + $content = trim( substr( $content, 5, strlen( $content ) ) ); + + return sprintf( + '%s', + esc_attr( $content ), + esc_html( + strlen( $content ) > 30 + ? substr( $content, 0, 30 ) . '...' + : $content + ) + ); + } + + /** + * Block styles display. + * + * @param array $item Item. + * @return string + */ + public function column_block_styles( $item ): string { + return sprintf( + '%s', + implode( ',', $item['block_styles'] ) + ); + } + + /** + * Post title display. + * + * @param array $item Item. + * @return string + */ + public function column_post_title( $item ): string { + $content = $item['post_title'] ?? null; + if ( empty( $content ) ) { + return ''; + } + + $title_tpl = '%2$s'; + $link_tpl = '%s'; + $page_uri = get_page_uri( $item['post_id'] ); + + return sprintf( + empty( $page_uri ) ? $title_tpl : $link_tpl, + $page_uri, + esc_attr( $content ), + ( strlen( $content ) > 45 ? substr( $content, 0, 45 ) . '...' : $content ) + ); + } + + /** + * Post ID display. + * + * @param array $item Item. + * @return string + */ + public function column_post_id( $item ): string { + return sprintf( + '%s', + get_edit_post_link( $item['post_id'] ), + $item['post_id'] + ); + } + + /** + * Full row display, edited for grouping functionality. + * + * @param array $item Item. + */ + public function single_row( $item ) { + $cols = $this->get_columns(); + $colspan = count( $cols ); + $first_col = array_key_first( $cols ); + + if ( $this->latest_row !== $item[ $first_col ] ) { + echo ''; + echo sprintf( + '%s', + esc_attr( $colspan ), + esc_html( $item[ $first_col ] ) + ); + echo ''; + } + + $this->latest_row = $item[ $first_col ]; + $item[ $first_col ] = ''; + parent::single_row( $item ); + } + + /** + * Add action links to a row + * + * @param array $item Item. + * @param string $column_name Current column name. + * @param string $primary Primary column name. + * + * phpcs:disable WordPress.WP.I18n.TextDomainMismatch + */ + protected function handle_row_actions( $item, $column_name, $primary ) { + return $this->row_actions( + ( new RowActions() )->get_post_actions( $item, $column_name, $primary ) + ); + } + + /** + * Show only top tablenav (duplicate form post bug) + * + * @param string $which Tablenav identifier. + */ + protected function display_tablenav( $which ) { + if ( 'bottom' === $which ) { + echo '
'; + echo esc_html( parent::pagination( $which ) ); + echo '
'; + return; + } + parent::display_tablenav( $which ); + } + + /** + * Search parameters + */ + public function get_search_params(): Parameters { + return $this->search_params; + } + + /** + * Table URL + */ + public static function url(): string { + return admin_url( 'admin.php?page=plugin_blocks_report' ); + } + + /** + * Set table hooks + */ + public static function set_hooks(): void { + // Add redirection for filter action. + add_action( + 'admin_action_' . self::ACTION_NAME, + function () { + $nonce = $_GET['_wpnonce'] ?? null; + if ( ! wp_verify_nonce( $nonce, 'bulk-' . self::PLURAL ) ) { + wp_safe_redirect( self::url() ); + exit; + } + + $redirect_query = remove_query_arg( + [ '_wp_http_referer', '_wpnonce', 'action', 'filter_action' ], + \wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_QUERY ) + ); + \parse_str( $redirect_query, $args ); + $args = array_filter( + $args, + fn( $e ) => ! empty( $e ) && '0' !== $e + ); + + wp_safe_redirect( add_query_arg( $args, self::url() ) ); + exit; + }, + 10 + ); + } +} diff --git a/src/BlockReportSearch/Block/Query.php b/src/BlockReportSearch/Block/Query.php new file mode 100644 index 0000000000..9e48b1854d --- /dev/null +++ b/src/BlockReportSearch/Block/Query.php @@ -0,0 +1,22 @@ + $value ) { + if ( null === $value ) { + continue; + } + + $query = $query->with( $field, $value ); + } + + return $query; + } + + /** + * Generate query param object from HTTP request. + * + * @param array $request The request parameters. + * + * @return self Parameters + */ + public static function from_request( array $request ): self { + $text_search = ! empty( $request['s'] ) ? $request['s'] : null; + + if ( ! empty( $request['name'] ) ) { + $request['namespace'] = null; + } + + return self::from_array( + [ + 'namespace' => $request['namespace'] ?? null, + 'name' => $request['name'] ?? null, + 'content' => $text_search, + 'attributes' => $request['attributes'] ?? [ $text_search ], + 'post_status' => $request['post_status'] ?? self::DEFAULT_POST_STATUS, + 'post_type' => $request['post_type'] ?? null, + 'order' => $request['order'] ?? null, + ] + ); + } + + /** + * + * + * @param string $name The name. + * @param array $args The arguments. + * + * @throws \BadMethodCallException Method does not exists. + * + * @return mixed + */ + public function __call( string $name, array $args ) { + if ( strpos( $name, 'with_' ) === 0 ) { + $property = substr( $name, 5 ); + return $this->with( $property, $args[0] ); + } + + throw new \BadMethodCallException( 'Method ' . $name . ' does not exist.' ); + } + + /** + * Sets parameter + * + * @param string $name The name. + * @param mixed $value The value. + * + * @throws \BadMethodCallException Property not allowed. + * + * @return self Immutable parameter object. + */ + public function with( string $name, $value = null ): self { + $allowed = [ 'namespace', 'name', 'attributes', 'content', 'post_status', 'post_type', 'order' ]; + if ( ! in_array( $name, $allowed, true ) ) { + throw new \BadMethodCallException( 'Property ' . $name . ' does not exist.' ); + } + + $this->$name = $value ?? null; + return $this; + } + + /** + * Block namespace. + */ + public function namespace(): ?string { + return $this->namespace; + } + + /** + * Full block name. + */ + public function name(): ?string { + return $this->name; + } + + /** + * Block attributes. + */ + public function attributes(): ?array { + return $this->attributes; + } + + /** + * Block options content. + */ + public function content(): ?string { + return $this->content; + } + + /** + * List of required post status. + */ + public function post_status(): ?array { + return $this->post_status ?? self::DEFAULT_POST_STATUS; + } + + /** + * List of required post types. + */ + public function post_type(): ?array { + return $this->post_type ?? null; + } + + /** + * Columns names to sort on. + */ + public function order(): ?array { + return $this->order; + } +} diff --git a/src/BlockReportSearch/Block/Sql/Like.php b/src/BlockReportSearch/Block/Sql/Like.php new file mode 100644 index 0000000000..6f88d7978e --- /dev/null +++ b/src/BlockReportSearch/Block/Sql/Like.php @@ -0,0 +1,48 @@ +params = $params; + } + + /** + * @todo: $params->attributes not implemented. + */ + public function __toString(): string + { + $block_ns = $this->params->namespace(); + $block_name = $this->params->name(); + + if ('core' === $block_ns) { + $block = '%'; + } elseif ($block_name && 0 === strpos($block_name, 'core/')) { + $block = explode('/', $block_name)[1]; + } else { + $name = $block_name ? $block_name : '%'; + $block = $block_ns ? $block_ns . '/%' : $name; + } + $attrs = $this->params->content() ? '%' . $this->params->content() . '%' : '%'; + + return sprintf('%%.*', $block, $attrs); + } +} diff --git a/src/BlockReportSearch/Block/Sql/SqlQuery.php b/src/BlockReportSearch/Block/Sql/SqlQuery.php new file mode 100644 index 0000000000..1d3d390209 --- /dev/null +++ b/src/BlockReportSearch/Block/Sql/SqlQuery.php @@ -0,0 +1,116 @@ +db = $db ? $db : $wpdb; + } + + /** + * @param Block\Query\Parameters ...$params_list Query parameters. + * @return int[] List of posts IDs. + */ + public function get_posts(Block\Query\Parameters ...$params_list): array + { + $query = $this->get_sql_query(...$params_list); + $results = $this->db->get_results($query); + + return array_map( + function ($r) { + return (int) $r->ID; + }, + $results + ); + } + + /** + * @param Block\Query\Parameters ...$params_list Query parameters. + * @return string SQL query string + * @throws \UnexpectedValueException Empty prepared query. + */ + private function get_sql_query(Block\Query\Parameters ...$params_list): string + { + // Prepare query parameters. + $like = []; + $status = []; + $type = []; + $order = []; + foreach ($params_list as $params) { + $like[] = ( new Like($params) )->__toString(); + $status = array_merge($status, $params->post_status() ?? []); + $type = array_merge($type, $params->post_type() ?? []); + $order = array_merge($order, $params->order() ?? []); + } + $status = array_unique(array_filter($status)); + $type = array_unique(array_filter($type)); + $order = $this->parse_order(array_unique(array_filter($order))); + + // Prepare query. + $sql_params = new SqlParameters(); + $sql = 'SELECT ID + FROM ' . $sql_params->identifier($this->db->posts) . ' + WHERE post_status IN ' . $sql_params->string_list($status); + if (! empty($type)) { + $sql .= ' AND post_type IN ' . $sql_params->string_list($type); + } + foreach ($like as $l) { + $sql .= ' AND post_content LIKE ' . $sql_params->string($l) . ' '; + } + if (! empty($order)) { + $sql .= ' ORDER BY ' . implode(',', $order); + } + + $query = $this->db->prepare($sql, $sql_params->get_values()); + if (empty($query)) { + throw new \UnexpectedValueException('Search query is invalid'); + } + + return $query; + } + + /** + * Parse and filter order parameter + * + * @param string[] $order List of sort columns. + * @return string[] + */ + private function parse_order(array $order): array + { + $parsed = []; + foreach ($order as $k) { + switch ($k) { + case 'block_type': + break; + case 'post_id': + $parsed[] = 'ID'; + break; + default: + $parsed[] = $k; + } + } + return $parsed; + } +} diff --git a/src/BlockReportSearch/BlockSearch.php b/src/BlockReportSearch/BlockSearch.php new file mode 100644 index 0000000000..50f056c8b3 --- /dev/null +++ b/src/BlockReportSearch/BlockSearch.php @@ -0,0 +1,47 @@ +query = $query ?? new SqlQuery(); + } + + /** + * @param Parameters $params Query parameters. + * @return int[] list of posts IDs. + */ + public function get_posts( Parameters $params ): array { + return $this->query->get_posts( $params ); + } + + /** + * @param string $block_name Query parameters. + * @return int[] list of posts IDs. + */ + public function get_posts_with_block( string $block_name ): array { + return $this->get_posts( + ( new Parameters() )->with_name( $block_name ) + ); + } +} diff --git a/src/BlockReportSearch/Pattern/ContentStructure.php b/src/BlockReportSearch/Pattern/ContentStructure.php new file mode 100644 index 0000000000..299a10c52c --- /dev/null +++ b/src/BlockReportSearch/Pattern/ContentStructure.php @@ -0,0 +1,181 @@ +parser = new WP_Block_Parser(); + } + + /** + * Parse content as structure. + * + * @param string $content Post content. + */ + public function parse_content(string $content): void + { + $this->content = $content; + $this->make_content_tree($this->content); + $this->make_structure_signature($this->tree); + } + + /** + * @return string|null Content + */ + public function get_content(): ?string + { + return $this->content; + } + + /** + * @return array Content tree + */ + public function get_content_tree(): array + { + return $this->tree; + } + + /** + * @return string Content signature + */ + public function get_content_signature(): string + { + return $this->signature; + } + + /** + * Make tree structure from content. + * + * @param string $content Post content. + */ + public function make_content_tree(string $content): void + { + $parsed = $this->parser->parse($content); + $tree = []; + while (! empty($parsed)) { + /** @var array $block */ + $block = array_shift($parsed); + $tree[] = $this->make_tree($block); + } + + $this->tree = array_values(array_filter($tree)); + } + + /** + * Make signature from tree structure. + * + * @param array $tree Tree. + */ + public function make_structure_signature(array $tree): void + { + $this->normalize_tree_for_signature($tree); + $signature = json_encode($tree); // phpcs:ignore WordPress.WP.AlternativeFunctions + + $this->signature = trim($signature, '[]'); + } + + /** + * Normalize tree to remove duplications. + * + * @param array $tree Tree. + */ + public function normalize_tree_for_signature(array &$tree): void + { + // No classes in content signature. + if (isset($tree['classes'])) { + unset($tree['classes']); + } + + foreach ($tree as $key => &$node) { + // No classes in content signature. + if (isset($node['classes'])) { + unset($node['classes']); + } + + if (empty($node['children']) || ! is_array($node['children'])) { + continue; + } + + if ('core/columns' === $node['name']) { + $columns_count = count($node['children']); + $unique_columns = array_unique($node['children'], \SORT_REGULAR); + $unique_count = count($unique_columns); + + if (1 === $unique_count && $columns_count > $unique_count) { + $node['children'] = $unique_columns; + } + } + + if ('core/group' === $node['name']) { + $subgroups_count = count($node['children']); + $unique_subgroups = array_unique($node['children'], \SORT_REGULAR); + $unique_count = count($unique_subgroups); + + if (1 === $unique_count && $subgroups_count > $unique_count) { + $node['children'] = $unique_subgroups; + } + } + + if (empty($node['children'])) { + continue; + } + + $this->normalize_tree_for_signature($node['children']); + } + } + + /** + * Make blocks tree + * + * @param array $block Block. + * + * @return array|null Tree representation of block content. + */ + public function make_tree(array $block): ?array + { + if (empty($block['blockName'])) { + return null; + } + + return [ + 'name' => $block['blockName'], + 'classes' => array_filter( + explode(' ', $block['attrs']['className'] ?? '') + ), + 'children' => empty($block['innerBlocks']) + ? null + : array_values( + array_filter( + array_map( + fn ($b) => $this->make_tree($b), + $block['innerBlocks'] + ) + ) + ), + ]; + } +} diff --git a/src/BlockReportSearch/Pattern/PatternData.php b/src/BlockReportSearch/Pattern/PatternData.php new file mode 100644 index 0000000000..f9ebf20910 --- /dev/null +++ b/src/BlockReportSearch/Pattern/PatternData.php @@ -0,0 +1,125 @@ +get_registered( $name ) + ); + } + + /** + * @param array $pattern Pattern data from registry. + */ + public static function from_pattern( array $pattern ): self { + $data = new self(); + + $data->name = $pattern['name']; + $data->title = $pattern['title']; + $data->content = $pattern['content'] ?? ''; + + $data->description = $pattern['description'] ?? null; + $data->viewport_width = $pattern['viewportWidth'] ?? null; + $data->block_types = $pattern['blockTypes'] ?? null; + $data->post_types = $pattern['postTypes'] ?? null; + $data->keywords = $pattern['keywords'] ?? null; + + $struct = new ContentStructure(); + $struct->parse_content( $data->content ); + + $data->structure = $struct->get_content_tree(); + $data->signature = $struct->get_content_signature(); + $data->block_list = BlockList::parse_block_list( $data->content ); + $data->classname = self::make_classname( $data->name ); + + return $data; + } + + /** + * @param string $name Pattern name. + */ + public static function make_classname( string $name ): string { + return 'is-pattern-' . preg_replace( '#[^_a-zA-Z0-9-]#', '-', $name ); + } +} diff --git a/src/BlockReportSearch/Pattern/PatternUsage.php b/src/BlockReportSearch/Pattern/PatternUsage.php new file mode 100644 index 0000000000..155b6e06cd --- /dev/null +++ b/src/BlockReportSearch/Pattern/PatternUsage.php @@ -0,0 +1,202 @@ +search = $search ?? new PatternSearch(); + $this->parser = $parser ?? new WP_Block_Parser(); + $this->registry = WP_Block_Patterns_Registry::get_instance(); + } + + /** + * @param Parameters $params Search parameters. + * @param array $opts Options. + */ + public function get_patterns(Parameters $params, array $opts = []): array + { + $opts = array_merge( + [ + 'use_struct' => true, + 'use_class' => true, + 'use_templates' => true, + ], + $opts + ); + + $posts_ids = $this->search->get_posts($params, $opts); + + return $this->get_filtered_patterns($posts_ids, $params, $opts); + } + + /** + * @param int[] $posts_ids Posts ids. + * @param Parameters $params Search parameters. + * @param array $opts Parsing options. + */ + private function get_filtered_patterns( + array $posts_ids, + Parameters $params, + array $opts + ): array { + $chunks = array_chunk($posts_ids, 50); + + $post_args = [ + 'orderby' => empty($params->order()) ? null : array_fill_keys($params->order(), 'ASC'), + 'post_status' => $params->post_status(), + 'post_type' => $params->post_type(), + ]; + + if ($opts['use_templates']) { + $templates = self::patterns_templates_lookup_table(); + } + + $patterns = []; + foreach ($chunks as $chunk) { + /** @var WP_Post[] $posts */ + $posts = get_posts(array_merge([ 'include' => $chunk ], $post_args)); + + foreach ($posts as $post) { + $post_struct = new ContentStructure(); + $post_struct->parse_content($post->post_content ?? ''); + + foreach ($params->name() as $pattern_name) { + $pattern = PatternData::from_name($pattern_name); + + // struct matches. + if ($opts['use_struct']) { + $struct_occ = substr_count( + $post_struct->get_content_signature(), + $pattern->signature + ); + if ($struct_occ > 0) { + $patterns[] = $this->format_pattern_data( + $pattern, + $post, + $struct_occ, + 'structure' + ); + } + } + + // class matches. + if ($opts['use_class']) { + $class_occ = round( + substr_count($post->post_content, $pattern->classname) / 2 + ); + if ($class_occ > 0) { + $patterns[] = $this->format_pattern_data( + $pattern, + $post, + $class_occ, + 'classname' + ); + } + } + + if (!$opts['use_templates'] || empty($templates[ $pattern->name ])) { + continue; + } + + $template_occ = substr_count( + $post->post_content, + ' +

+ + ', + ]; + } +} diff --git a/src/Patterns/BlockPattern.php b/src/Patterns/BlockPattern.php index 4277095c5e..db81917a35 100644 --- a/src/Patterns/BlockPattern.php +++ b/src/Patterns/BlockPattern.php @@ -47,6 +47,7 @@ public static function get_list(): array QuickLinks::class, DeepDive::class, HighlightedCta::class, + BlankPage::class, ]; } diff --git a/templates/duplicate-postmeta-report.twig b/templates/duplicate-postmeta-report.twig new file mode 100644 index 0000000000..5ea724d235 --- /dev/null +++ b/templates/duplicate-postmeta-report.twig @@ -0,0 +1,56 @@ +{% block duplicate_postmeta_report %} + +
+

{{ __( 'Duplicate Postmeta', 'planet4-blocks-backend' ) }}

+
+

{{ __( 'Whitelisted meta_key\'s for delete operation', 'planet4-blocks-backend' ) }} -

+ {% for postmeta_key in postmeta_keys %} +

- {{ postmeta_key }}

+ {% endfor %} + +
+

+ + +

+ {{ message }} +

+
+
+ +
+

{{ __( 'Duplicate postmeta report', 'planet4-blocks-backend' ) }}

+ + + + + + + {% set total_count = 0 %} + {% for result in duplicate_postmeta %} + {% set duplicate_count = result.all_count - result.unique_count %} + {% set total_count = total_count + duplicate_count %} + + + + + + {% endfor %} + {% if not duplicate_postmeta %} + + + + + {% else %} + + + + + + + + + {% endif %} +
{{ __( 'No.', 'planet4-blocks-backend' ) }}{{ __( 'meta_key', 'planet4-blocks-backend' ) }}{{ __( 'Duplicate count', 'planet4-blocks-backend' ) }}
{{ loop.index }}{{ result.meta_key }} {% if result.meta_key in postmeta_keys %} * {% endif %} {{ duplicate_count }}
{{ __( 'No duplicate postmeta records found.', 'planet4-blocks-backend' ) }}
{{ __( 'Total', 'planet4-blocks-backend' ) }}{{ total_count }}
{{ __( 'The * indicates whitelisted metakey\'s for delete operation.', 'planet4-blocks-backend' ) }}
+ +{% endblock %}