array( 'sql' => 'p.post_date', 'default_order' => 'desc' ), 'inbound' => array( 'sql' => 'inbound_count', 'default_order' => 'desc' ), 'outbound' => array( 'sql' => 'outbound_count', 'default_order' => 'desc' ), 'title' => array( 'sql' => 'p.post_title', 'default_order' => 'asc' ), ); /** * Active filter. * * @var string */ private string $active_filter; /** * Active sort key (one of the SORT_OPTIONS keys). * * @var string */ private string $active_sort; /** * Active sort direction, 'asc' or 'desc'. * * @var string */ private string $active_order; /** * Per-filter counts for the segmented filter control. * * @var array */ private array $filter_counts = array(); /** * @since 1.0.0 */ public function __construct() { parent::__construct( array( 'singular' => 'post', 'plural' => 'posts', 'ajax' => false, ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter param. $raw_filter = isset( $_GET['filter'] ) ? sanitize_key( (string) $_GET['filter'] ) : 'all'; $this->active_filter = in_array( $raw_filter, self::VALID_FILTERS, true ) ? $raw_filter : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only sort param. $raw_sort = isset( $_GET['orderby'] ) ? sanitize_key( (string) $_GET['orderby'] ) : 'post_date'; $this->active_sort = array_key_exists( $raw_sort, self::SORT_OPTIONS ) ? $raw_sort : 'post_date'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only sort param. $raw_order = isset( $_GET['order'] ) ? strtolower( sanitize_key( (string) $_GET['order'] ) ) : ''; $this->active_order = in_array( $raw_order, array( 'asc', 'desc' ), true ) ? $raw_order : self::SORT_OPTIONS[ $this->active_sort ]['default_order']; } /** * Returns the active filter. * * @since 1.1.0 * * @return string */ public function get_active_filter(): string { return $this->active_filter; } /** * Returns the active sort key. * * @since 1.4.6 * * @return string */ public function get_active_sort(): string { return $this->active_sort; } /** * Returns the active sort direction. * * @since 1.4.6 * * @return string */ public function get_active_order(): string { return $this->active_order; } /** * Sort options for the "Sort by" control — key => plain-language label. * * @since 1.4.6 * * @return array */ public function get_sort_option_labels(): array { return array( 'post_date' => __( 'Most recent', 'postlinkly' ), 'inbound' => __( 'Most linked to', 'postlinkly' ), 'outbound' => __( 'Most links out', 'postlinkly' ), 'title' => __( 'Title A–Z', 'postlinkly' ), ); } /** * Returns the filter counts for the segmented filter control. * * @since 1.1.0 * * @return array */ public function get_filter_counts(): array { return $this->filter_counts; } /** * WP_List_Table requires this; the compact-list design has no column * headers, so this is only used internally for screen-reader text. * * @since 1.0.0 * * @return array */ public function get_columns(): array { return array( 'post_title' => __( 'Source', 'postlinkly' ), ); } /** * No-items message. * * @since 1.0.0 * * @return void */ public function no_items(): void { $messages = array( 'all' => __( 'No posts indexed yet. Run a scan from the Tools page to populate this report.', 'postlinkly' ), 'post' => __( 'No posts found.', 'postlinkly' ), 'page' => __( 'No pages found.', 'postlinkly' ), 'broken' => __( 'No broken links detected. All internal links resolve to published content.', 'postlinkly' ), 'orphan' => __( 'Every published post has at least one link pointing to it. Nothing to catch up on here.', 'postlinkly' ), ); echo esc_html( $messages[ $this->active_filter ] ?? $messages['all'] ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- Messages are pre-translated in the array above. } /** * Builds items and populates filter counts. * * @since 1.0.0 * * @return void */ public function prepare_items(): void { $this->_column_headers = array( $this->get_columns(), array(), array() ); $this->populate_filter_counts(); if ( 'orphan' === $this->active_filter ) { $this->prepare_orphans(); } else { $this->prepare_posts(); } } /** * Tags and attributes a rendered report row may contain, for wp_kses(). * * render_item() escapes every value it interpolates, so this list does not * make a row safe -- it makes that safety checkable where the row is * echoed, and strips anything a later change adds without escaping. * Built from the tags and attributes real rows actually produce. * * @since 1.17.2 * * @return array> */ public static function allowed_item_html(): array { return array( 'div' => array( 'class' => true, 'style' => true, 'data-post-id' => true, ), 'span' => array( 'class' => true ), 'code' => array(), 'a' => array( 'href' => true, 'class' => true, 'title' => true, 'target' => true, 'rel' => true, ), 'button' => array( 'type' => true, 'class' => true, 'title' => true, 'aria-expanded' => true, 'data-metric' => true, 'data-post-id' => true, ), ); } /** * Renders one post as a compact, expandable row. * * Bypasses WP_List_Table's per-column renderer entirely — this design * has no columns to click, just a title, three metric pills, and * actions. Every filter (all/broken/orphan) produces the same item * shape, so this is the only rendering path; a row never looks * different depending on which query found it. * * @since 1.4.6 * * @param object $item Row data. * * @return string */ public function render_item( $item ): string { $post_id = (int) ( $item->post_id ?? 0 ); $title = '' !== (string) ( $item->post_title ?? '' ) ? (string) $item->post_title : __( '(no title)', 'postlinkly' ); $slug = (string) ( $item->post_name ?? '' ); $type = ucfirst( (string) ( $item->post_type ?? '' ) ); $date = ! empty( $item->post_date ) ? date_i18n( get_option( 'date_format' ), strtotime( (string) $item->post_date ) ) : ''; $inbound = (int) ( $item->inbound_count ?? 0 ); $outbound = (int) ( $item->outbound_count ?? 0 ); $external = (int) ( $item->external_count ?? 0 ); $broken = (int) ( $item->broken_count ?? 0 ); $is_orphan = 0 === $inbound; $is_broken = $broken > 0; $item_class = 'pl-report-item'; if ( $is_broken ) { $item_class .= ' pl-report-item--broken'; } elseif ( $is_orphan ) { $item_class .= ' pl-report-item--orphan'; } $flag = ''; if ( $is_broken ) { $flag = '' . esc_html( sprintf( /* translators: %d is the broken link count. */ _n( '%d broken', '%d broken', $broken, 'postlinkly' ), $broken ) ) . ''; } elseif ( $is_orphan ) { // Deliberately not "Orphan" — that word already means something // else in WordPress (orphaned media attachments), which is a // real source of confusion, not just unfamiliar jargon. $flag = '' . esc_html__( 'Not linked yet', 'postlinkly' ) . ''; } $meta_parts = array(); if ( '' !== $slug ) { $meta_parts[] = '/' . esc_html( $slug ) . '/'; } $meta_parts[] = esc_html( $type ); if ( '' !== $date ) { $meta_parts[] = esc_html( sprintf( /* translators: %s is a formatted date. */ __( 'published %s', 'postlinkly' ), $date ) ); } $edit_link = get_edit_post_link( $post_id ); $view_link = get_permalink( $post_id ); $actions = '' . esc_html__( 'Edit', 'postlinkly' ) . ''; if ( $view_link ) { $actions .= ' ' . esc_html__( 'View', 'postlinkly' ) . ''; } if ( $is_broken ) { $actions = '' . esc_html__( 'Fix links', 'postlinkly' ) . ' ' . $actions; } elseif ( $is_orphan ) { $actions = '' . esc_html__( 'Find posts to link here', 'postlinkly' ) . ' ' . $actions; } $out = '
'; $out .= '
'; $out .= '
'; $out .= '' . esc_html( $title ) . $flag . ''; $out .= '
' . implode( ' · ', $meta_parts ) . '
'; $out .= '
'; $out .= '
'; $out .= $this->render_metric_button( $post_id, 'inbound', __( 'In', 'postlinkly' ), $inbound, __( 'Posts that link to this one', 'postlinkly' ) ); $out .= $this->render_metric_button( $post_id, 'outbound', __( 'Out', 'postlinkly' ), $outbound, __( 'Links this post makes to other posts', 'postlinkly' ), $is_broken ); $out .= $this->render_metric_button( $post_id, 'external', __( 'Ext', 'postlinkly' ), $external, __( 'Links to other websites', 'postlinkly' ) ); $out .= '
'; $out .= '
' . $actions . '
'; $out .= '
'; $out .= ''; $out .= '
'; return $out; } /** * Renders one clickable metric pill (In / Out / Ext). * * @since 1.4.6 * * @param int $post_id Post ID this row belongs to. * @param string $metric_key 'inbound' | 'outbound' | 'external'. * @param string $label Short pill label ("In", "Out", "Ext"). * @param int $count The count to display. * @param string $tooltip Plain-language explanation shown on hover. * @param bool $has_broken Whether this metric includes broken links. * * @return string */ private function render_metric_button( int $post_id, string $metric_key, string $label, int $count, string $tooltip, bool $has_broken = false ): string { $class = 'pl-report-metric'; if ( $has_broken ) { $class .= ' pl-report-metric--has-broken'; } $value_class = 0 === $count ? ' pl-report-metric-value--zero' : ''; return ''; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Populates filter_counts for the segmented filter control. * * @since 1.1.0 * * @return void */ private function populate_filter_counts(): void { global $wpdb; $table = TableNames::report_links(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $counts = $wpdb->get_results( $wpdb->prepare( "SELECT p.post_type, COUNT(DISTINCT p.ID) AS post_count, SUM(CASE WHEN broken_agg.broken_count > 0 THEN 1 ELSE 0 END) AS broken_posts FROM {$wpdb->posts} AS p LEFT JOIN ( SELECT rl.post_id, SUM(CASE WHEN rl.is_internal = 1 AND rl.target_id > 0 AND ( pt.ID IS NULL OR pt.post_status != 'publish' ) THEN 1 ELSE 0 END) AS broken_count FROM %i AS rl LEFT JOIN {$wpdb->posts} AS pt ON pt.ID = rl.target_id AND rl.is_internal = 1 GROUP BY rl.post_id ) AS broken_agg ON broken_agg.post_id = p.ID WHERE p.post_status = 'publish' GROUP BY p.post_type", $table ), ARRAY_A ); // phpcs:enable $this->filter_counts = array( 'all' => 0, 'post' => 0, 'page' => 0, 'broken' => 0, 'orphan' => ( new OrphanedPostsDetector() )->count(), ); if ( is_array( $counts ) ) { foreach ( $counts as $row ) { if ( ! is_array( $row ) ) { continue; } $type = (string) ( $row['post_type'] ?? '' ); $count = (int) ( $row['post_count'] ?? 0 ); $this->filter_counts['all'] += $count; if ( isset( $this->filter_counts[ $type ] ) ) { $this->filter_counts[ $type ] = $count; } $this->filter_counts['broken'] += (int) ( $row['broken_posts'] ?? 0 ); } } } /** * Fetches aggregated post rows for the current filter. * * One row per published post with inbound / outbound / external / * broken counts, regardless of whether the post has any outbound * links at all (previously an INNER JOIN silently excluded posts with * zero outbound links from the whole report; a LEFT JOIN here means a * brand-new post with no links yet still shows up as 0/0/0 instead of * vanishing). * * @since 1.1.0 * @since 1.4.6 LEFT JOIN instead of INNER JOIN; real ORDER BY support. * * @return void */ private function prepare_posts(): void { global $wpdb; $per_page = 25; $current_page = $this->get_pagenum(); $offset = ( $current_page - 1 ) * $per_page; $table = TableNames::report_links(); $where = $this->build_where_clause(); $order = $this->build_order_clause(); // The WHERE fragment is built from the fixed VALID_FILTERS whitelist in // build_where_clause(), and the ORDER BY column from the fixed // SORT_OPTIONS whitelist in build_order_clause() — no request text is // interpolated into SQL. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} AS p LEFT JOIN %i AS rl ON rl.post_id = p.ID LEFT JOIN {$wpdb->posts} AS pt ON pt.ID = rl.target_id AND rl.is_internal = 1 {$where}", $table ) ); $this->items = (array) $wpdb->get_results( $wpdb->prepare( "SELECT p.ID AS post_id, p.post_title, p.post_name, p.post_type, p.post_date, SUM(CASE WHEN rl2.target_id = p.ID AND rl2.is_internal = 1 THEN 1 ELSE 0 END) AS inbound_count, SUM(CASE WHEN rl.is_internal = 1 THEN 1 ELSE 0 END) AS outbound_count, SUM(CASE WHEN rl.is_internal = 0 THEN 1 ELSE 0 END) AS external_count, SUM(CASE WHEN rl.is_internal = 1 AND rl.target_id > 0 AND ( pt.ID IS NULL OR pt.post_status != 'publish' ) THEN 1 ELSE 0 END) AS broken_count FROM {$wpdb->posts} AS p LEFT JOIN %i AS rl ON rl.post_id = p.ID LEFT JOIN {$wpdb->posts} AS pt ON pt.ID = rl.target_id AND rl.is_internal = 1 LEFT JOIN %i AS rl2 ON rl2.target_id = p.ID AND rl2.is_internal = 1 {$where} GROUP BY p.ID, p.post_title, p.post_name, p.post_type, p.post_date {$order} LIMIT %d OFFSET %d", $table, $table, $per_page, $offset ) ); // phpcs:enable $this->set_pagination_args( array( 'total_items' => $total, 'per_page' => $per_page, 'total_pages' => (int) ceil( $total / $per_page ), ) ); } /** * Fetches posts with zero inbound links, normalized to the same item * shape `prepare_posts()` produces (post_id, outbound/external/broken * counts all present, defaulted to 0) so `render_item()` never has to * special-case which query found a row. * * @since 1.1.0 * @since 1.4.6 Normalized item shape; real ORDER BY/pagination-friendly * slice (still detector-backed since it covers posts with * zero outbound links too, which the aggregate query's * "broken" detection doesn't need to re-derive here). * * @return void */ private function prepare_orphans(): void { $per_page = 25; $current_page = $this->get_pagenum(); $offset = ( $current_page - 1 ) * $per_page; $detector = new OrphanedPostsDetector(); $all = $detector->detect( 500 ); $total = count( $all ); $page_slice = array_slice( $all, $offset, $per_page ); $this->items = array_map( static function ( $row ) { return (object) array( 'post_id' => (int) ( $row->ID ?? 0 ), 'post_title' => (string) ( $row->post_title ?? '' ), 'post_name' => (string) ( $row->post_name ?? '' ), 'post_type' => (string) ( $row->post_type ?? '' ), 'post_date' => (string) ( $row->post_date ?? '' ), 'inbound_count' => 0, 'outbound_count' => 0, 'external_count' => 0, 'broken_count' => 0, ); }, $page_slice ); $this->set_pagination_args( array( 'total_items' => $total, 'per_page' => $per_page, 'total_pages' => (int) ceil( $total / $per_page ), ) ); } /** * Builds the WHERE clause for the active filter. * * @since 1.1.0 * * @return string SQL WHERE fragment or empty string. */ private function build_where_clause(): string { global $wpdb; $base = "WHERE p.post_status = 'publish'"; switch ( $this->active_filter ) { case 'post': return $base . $wpdb->prepare( ' AND p.post_type = %s', 'post' ); case 'page': return $base . $wpdb->prepare( ' AND p.post_type = %s', 'page' ); case 'broken': return $base . $wpdb->prepare( ' AND rl.is_internal = %d AND rl.target_id > 0 AND ( pt.ID IS NULL OR pt.post_status != %s )', 1, 'publish' ); default: return $base; } } /** * Builds the ORDER BY clause from the active sort key/direction, both * validated against fixed whitelists in the constructor — never built * from raw request text. * * @since 1.4.6 * * @return string */ private function build_order_clause(): string { $sql_column = self::SORT_OPTIONS[ $this->active_sort ]['sql']; $direction = 'asc' === $this->active_order ? 'ASC' : 'DESC'; return "ORDER BY {$sql_column} {$direction}"; } }