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 '
'; + } + + /** + * 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 %} + +
+

+ + +

+ {{ 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 %}