diff --git a/src/BlockReportSearch/Block/BlockUsage.php b/src/BlockReportSearch/Block/BlockUsage.php
new file mode 100644
index 0000000000..a83e01869f
--- /dev/null
+++ b/src/BlockReportSearch/Block/BlockUsage.php
@@ -0,0 +1,292 @@
+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'])) {
+ continue;
+ }
+
+ $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..da01d1c462
--- /dev/null
+++ b/src/BlockReportSearch/Block/BlockUsageApi.php
@@ -0,0 +1,99 @@
+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..ad91aa8519
--- /dev/null
+++ b/src/BlockReportSearch/Block/BlockUsageTable.php
@@ -0,0 +1,663 @@
+ 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..fa238a7062
--- /dev/null
+++ b/src/BlockReportSearch/Block/Query.php
@@ -0,0 +1,24 @@
+ $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..ae8d666439
--- /dev/null
+++ b/src/BlockReportSearch/BlockSearch.php
@@ -0,0 +1,52 @@
+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..f397d52edc
--- /dev/null
+++ b/src/BlockReportSearch/Pattern/PatternData.php
@@ -0,0 +1,129 @@
+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 %}
+
+
+
+
+
+ {{ __( '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 %}