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 '
';
+ foreach ( $unique_views as $class => $view ) {
+ $views[ $class ] = "\t- $view";
+ }
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo implode( " |
\n", $views ) . "\n";
+ 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..ef74d1cc8d
--- /dev/null
+++ b/src/BlockReportSearch/Pattern/ContentStructure.php
@@ -0,0 +1,180 @@
+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 ) {
+ // 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'] ) ) {
+ $this->normalize_tree_for_signature( $node['children'] );
+ }
+ }
+ }
+
+ /**
+ * Make blocks tree
+ *
+ * @param array $block Block.
+ *
+ * @return null|array 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..af1aebbc4b
--- /dev/null
+++ b/src/BlockReportSearch/Pattern/PatternUsage.php
@@ -0,0 +1,204 @@
+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.
+ * @return array
+ */
+ 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.
+ * @return array
+ */
+ 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 ] ) ) {
+ $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 %}
+
+
+
+
+
+ {{ __( 'Duplicate postmeta report', 'planet4-blocks-backend' ) }}
+
+
+ {{ __( 'No.', 'planet4-blocks-backend' ) }} |
+ {{ __( 'meta_key', 'planet4-blocks-backend' ) }} |
+ {{ __( 'Duplicate count', '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 %}
+
+ {{ loop.index }} |
+ {{ result.meta_key }} {% if result.meta_key in postmeta_keys %} * {% endif %} |
+ {{ duplicate_count }} |
+
+ {% endfor %}
+ {% if not duplicate_postmeta %}
+ |
+
+ {{ __( 'No duplicate postmeta records found.', 'planet4-blocks-backend' ) }} |
+
+ {% else %}
+
+ |
+ {{ __( 'Total', 'planet4-blocks-backend' ) }} |
+ {{ total_count }} |
+
+
+ {{ __( 'The * indicates whitelisted metakey\'s for delete operation.', 'planet4-blocks-backend' ) }} |
+
+ {% endif %}
+
+
+{% endblock %}