report = $report; $this->formatter = $formatter; parent::__construct( array( 'singular' => 'visibility-block', 'plural' => 'visibility-blocks', 'ajax' => false, ) ); } /** * Declares the table columns. * * @return array */ public function get_columns(): array { return array( 'location' => __( 'Location', 'blocksieve' ), 'block' => __( 'Block', 'blocksieve' ), 'status' => __( 'Status', 'blocksieve' ), 'conditions' => __( 'Conditions', 'blocksieve' ), 'preview' => __( 'Preview', 'blocksieve' ), 'warnings' => __( 'Warnings', 'blocksieve' ), ); } /** * Declares which columns are sortable. * * @return array */ protected function get_sortable_columns(): array { return array( 'location' => array( 'location', false ), 'block' => array( 'block', false ), 'status' => array( 'status', false ), ); } /** * Message shown when nothing matches. * * @return void */ public function no_items(): void { esc_html_e( 'No blocks with visibility conditions were found.', 'blocksieve' ); } /** * Builds the view links above the table. * * @return array */ protected function get_views(): array { $all = $this->report->occurrences(); $current = $this->current_view(); $base_url = remove_query_arg( array( 'view', 'paged' ) ); $counts = array( 'all' => count( $all ), 'active' => $this->report->total_active(), 'disabled' => count( $all ) - $this->report->total_active(), 'warnings' => $this->report->total_warnings(), ); $labels = array( 'all' => __( 'All', 'blocksieve' ), 'active' => __( 'Active', 'blocksieve' ), 'disabled' => __( 'Disabled', 'blocksieve' ), 'warnings' => __( 'Needs attention', 'blocksieve' ), ); $views = array(); foreach ( $labels as $key => $label ) { $url = 'all' === $key ? $base_url : add_query_arg( 'view', $key, $base_url ); $class = $current === $key ? ' class="current"' : ''; $views[ $key ] = sprintf( '%3$s (%4$s)', esc_url( $url ), $class, esc_html( $label ), esc_html( number_format_i18n( $counts[ $key ] ) ) ); } return $views; } /** * Renders the source-kind and condition-type filters above the table, and * the note on how previews are computed beside the pagination below it. * * @param string $which Either 'top' or 'bottom'. * @return void */ protected function extra_tablenav( $which ): void { if ( 'bottom' === $which ) { printf( '

%s

', esc_html__( 'Preview shows how each block resolves for a logged-out visitor and an administrator. Conditions that depend on the URL or a query parameter can only be resolved during a real request.', 'blocksieve' ) ); return; } if ( 'top' !== $which ) { return; } $source = $this->requested( 'source' ); $condition = $this->requested( 'condition' ); echo '
'; echo ''; echo ''; echo ''; echo ''; submit_button( __( 'Filter', 'blocksieve' ), '', 'filter_action', false ); echo '
'; } /** * Filters, sorts and paginates the report into table rows. * * @return void */ public function prepare_items(): void { $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns() ); $items = $this->filter( $this->report->occurrences() ); $items = $this->sort( $items ); $per_page = 20; $total = count( $items ); $pages = max( 1, (int) ceil( $total / $per_page ) ); // Clamp: a stale `paged` (e.g. resubmitted with a narrower search) must // not slice past the end. Core's own redirect cannot run this late. $paged = min( $pages, max( 1, (int) $this->get_pagenum() ) ); $this->items = array_slice( $items, ( $paged - 1 ) * $per_page, $per_page ); $this->set_pagination_args( array( 'total_items' => $total, 'per_page' => $per_page, 'total_pages' => (int) ceil( $total / $per_page ), ) ); } /** * Applies the view, filters and search term. * * @param Occurrence[] $items Occurrences. * @return Occurrence[] */ private function filter( array $items ): array { $view = $this->current_view(); $source = $this->requested( 'source' ); $condition = $this->requested( 'condition' ); $search = self::lower( $this->requested( 's' ) ); return array_values( array_filter( $items, function ( Occurrence $item ) use ( $view, $source, $condition, $search ): bool { if ( 'active' === $view && ! $item->is_enabled() ) { return false; } if ( 'disabled' === $view && $item->is_enabled() ) { return false; } if ( 'warnings' === $view && ! $item->has_warnings() ) { return false; } if ( '' !== $source && $item->source_kind !== $source ) { return false; } if ( '' !== $condition && ! in_array( $condition, $item->condition_types(), true ) ) { return false; } if ( '' !== $search && false === strpos( self::lower( $this->searchable_text( $item ) ), $search ) ) { return false; } return true; } ) ); } /** * Builds the haystack the search box matches against. * * @param Occurrence $item Occurrence. * @return string */ private function searchable_text( Occurrence $item ): string { $text = implode( ' ', array_merge( array( $item->source_title, $item->source_label, $item->block_name ), $this->formatter->format_all( $item->conditions() ) ) ); // Titles are stored texturized (`’`); match what the user sees. return html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * Sorts rows by the requested column. * * @param Occurrence[] $items Occurrences. * @return Occurrence[] */ private function sort( array $items ): array { $orderby = $this->requested( 'orderby', 'location' ); $descend = 'desc' === strtolower( $this->requested( 'order', 'asc' ) ); $items = array_values( $items ); $positions = array_keys( $items ); // Sorts are only stable from PHP 8.0: break ties on the incoming order so // rows (and therefore pages) come out the same on PHP 7.4. usort( $positions, static function ( int $x, int $y ) use ( $items, $orderby ): int { $a = $items[ $x ]; $b = $items[ $y ]; if ( 'block' === $orderby ) { $order = strcmp( $a->block_name, $b->block_name ); } elseif ( 'status' === $orderby ) { $order = $b->is_enabled() <=> $a->is_enabled(); } else { // Default: by location, then document order within it. $order = strnatcmp( self::lower( $a->source_title ), self::lower( $b->source_title ) ); $order = 0 !== $order ? $order : strnatcmp( $a->path, $b->path ); } return 0 !== $order ? $order : $x <=> $y; } ); $items = array_map( static fn( int $position ): Occurrence => $items[ $position ], $positions ); return $descend ? array_reverse( $items ) : $items; } /** * Renders the Location column. * * @param Occurrence $item Occurrence. * @return string */ public function column_location( $item ): string { $title = '' !== $item->source_title ? $item->source_title : __( '(untitled)', 'blocksieve' ); $edit_url = EditLink::for_occurrence( $item ); $name = '' !== $edit_url ? sprintf( '%2$s', esc_url( $edit_url ), esc_html( $title ) ) : '' . esc_html( $title ) . ''; return $name . '
' . esc_html( $item->source_label ) . ''; } /** * Renders the Block column. * * @param Occurrence $item Occurrence. * @return string */ public function column_block( $item ): string { return sprintf( '%1$s
%2$s', esc_html( $this->block_label( $item->block_name ) ), esc_html( $item->block_name ) ); } /** * Renders the Status column. * * @param Occurrence $item Occurrence. * @return string */ public function column_status( $item ): string { $status = $item->is_enabled() ? '' . esc_html__( 'Active', 'blocksieve' ) . '' : '' . esc_html__( 'Disabled', 'blocksieve' ) . ''; $relation = 'any' === $item->relation() ? __( 'Match any', 'blocksieve' ) : __( 'Match all', 'blocksieve' ); return $status . '
' . esc_html( $relation ) . ''; } /** * Renders the Conditions column. * * @param Occurrence $item Occurrence. * @return string */ public function column_conditions( $item ): string { $lines = $this->formatter->format_all( $item->conditions() ); if ( array() === $lines ) { return '' . esc_html__( 'None', 'blocksieve' ) . ''; } $out = '
    '; foreach ( $lines as $line ) { $out .= '
  • ' . esc_html( $line ) . '
  • '; } $out .= '
'; return $out; } /** * Renders the Preview column. * * @param Occurrence $item Occurrence. * @return string */ public function column_preview( $item ): string { $rows = array( __( 'Visitor', 'blocksieve' ) => $item->preview['visitor'] ?? UsageAnalyzer::VERDICT_DEPENDS, __( 'Admin', 'blocksieve' ) => $item->preview['admin'] ?? UsageAnalyzer::VERDICT_DEPENDS, ); $out = '
    '; foreach ( $rows as $who => $verdict ) { $out .= sprintf( '
  • %1$s %3$s
  • ', esc_html( $who ), esc_attr( $verdict ), esc_html( $this->verdict_label( (string) $verdict ) ) ); } return $out . '
'; } /** * Renders the Warnings column. * * @param Occurrence $item Occurrence. * @return string */ public function column_warnings( $item ): string { if ( ! $item->has_warnings() ) { return '' . esc_html__( 'No warnings', 'blocksieve' ) . ''; } $out = '
    '; foreach ( $item->warnings as $warning ) { $out .= '
  • ' . esc_html( $warning ) . '
  • '; } return $out . '
'; } /** * Fallback renderer for any column without a dedicated method. * * @param Occurrence $item Occurrence. * @param string $column_name Column id. * @return string */ public function column_default( $item, $column_name ): string { return ''; } /** * Translated label for a preview verdict. * * @param string $verdict Verdict identifier. * @return string */ private function verdict_label( string $verdict ): string { switch ( $verdict ) { case UsageAnalyzer::VERDICT_SHOWN: return __( 'Shown', 'blocksieve' ); case UsageAnalyzer::VERDICT_HIDDEN: return __( 'Hidden', 'blocksieve' ); default: return __( 'Depends on request', 'blocksieve' ); } } /** * Resolves a block type's human title, falling back to its name. * * @param string $block_name Block type name. * @return string */ private function block_label( string $block_name ): string { $type = \WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); if ( $type instanceof \WP_Block_Type && '' !== $type->title ) { return $type->title; } return $block_name; } /** * Translated labels for the source kinds, for the filter dropdown. * * @return array */ private function source_labels(): array { return array( Occurrence::SOURCE_POST => __( 'Posts & pages', 'blocksieve' ), Occurrence::SOURCE_PATTERN => __( 'Synced patterns', 'blocksieve' ), Occurrence::SOURCE_TEMPLATE => __( 'Templates', 'blocksieve' ), Occurrence::SOURCE_TEMPLATE_PART => __( 'Template parts', 'blocksieve' ), Occurrence::SOURCE_WIDGET => __( 'Widgets', 'blocksieve' ), Occurrence::SOURCE_NAVIGATION => __( 'Navigation menus', 'blocksieve' ), ); } /** * Returns the active view key. * * @return string */ public function current_view(): string { $view = $this->requested( 'view', 'all' ); return in_array( $view, array( 'all', 'active', 'disabled', 'warnings' ), true ) ? $view : 'all'; } /** * Lower-cases text for case-insensitive search and sort, including * non-ASCII letters ("Über" / "über") when mbstring is available. * * @param string $text Text. * @return string */ private static function lower( string $text ): string { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); } /** * Reads a sanitised request parameter. * * The screen is capability-gated and read-only, and every value is used only * to filter an in-memory array, so no nonce is involved. * * @param string $key Parameter name. * @param string $fallback Value when absent. * @return string */ private function requested( string $key, string $fallback = '' ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filtering of a capability-gated report. if ( ! isset( $_REQUEST[ $key ] ) || ! is_scalar( $_REQUEST[ $key ] ) ) { return $fallback; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filtering of a capability-gated report. return sanitize_text_field( wp_unslash( (string) $_REQUEST[ $key ] ) ); } }