'mra-attachment', 'plural' => 'mra-attachments', 'ajax' => false, ) ); } // ------------------------------------------------------------------------- // WP_List_Table interface // ------------------------------------------------------------------------- /** * Returns all column headers. * * @return array */ public function get_columns() { return array( 'cb' => '', 'thumbnail' => __( 'Thumbnail', 'robotstxt-mediaaudit' ), 'filename' => __( 'Filename', 'robotstxt-mediaaudit' ), 'attachment_id' => __( 'ID', 'robotstxt-mediaaudit' ), 'usage_count' => __( 'Usage Count', 'robotstxt-mediaaudit' ), 'usages' => __( 'Usages', 'robotstxt-mediaaudit' ), 'external_status' => __( 'External Status', 'robotstxt-mediaaudit' ), 'external_scanned_at' => __( 'Last Scanned', 'robotstxt-mediaaudit' ), ); } /** * Returns sortable columns. * * @return array> */ protected function get_sortable_columns() { return array( 'attachment_id' => array( 'attachment_id', true ), 'usage_count' => array( 'usage_count', false ), 'external_scanned_at' => array( 'external_scanned_at', false ), ); } /** * Returns bulk actions. * * @return array */ protected function get_bulk_actions() { return array( 'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ), 'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ), 'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ), 'export_csv_alerts' => __( 'Export CSV (Alerts only)', 'robotstxt-mediaaudit' ), ); } /** * Fallback renderer for unrecognised columns. * * @param array $item Row data. * @param string $column_name Column slug. * * @return string */ protected function column_default( $item, $column_name ) { return ''; } /** * Renders the checkbox column. * * @param array $item Row data. * * @return string */ protected function column_cb( $item ) { $aid = $item['attachment_id']; return sprintf( '', is_numeric( $aid ) ? (int) $aid : 0 ); } /** * Renders a 40×40 thumbnail linked to the attachment. * * @param array $item Row data. * * @return string */ protected function column_thumbnail( $item ) { $aid_val = $item['attachment_id']; $url_val = $item['file_url']; $id = is_numeric( $aid_val ) ? (int) $aid_val : 0; $url = esc_url( is_string( $url_val ) ? $url_val : '' ); $thumb = wp_get_attachment_image( $id, array( 40, 40 ), false, array( 'class' => 'mra-thumb' ) ); if ( ! $thumb ) { $thumb = ''; } return sprintf( '%s', $url, $thumb ); } /** * Renders the filename column with a link to the original file. * * @param array $item Row data. * * @return string */ protected function column_filename( $item ) { $aid_val = $item['attachment_id']; $fn_val = $item['file_name']; $url_val = $item['file_url']; $id = is_numeric( $aid_val ) ? (int) $aid_val : 0; $filename = esc_html( is_string( $fn_val ) ? $fn_val : '' ); $url = esc_url( is_string( $url_val ) ? $url_val : '' ); $title = sprintf( '%s', $url, ( '' !== $filename ? $filename : '—' ) ); $raw_edit_link = get_edit_post_link( $id ); $actions = array( 'edit' => sprintf( '%s', esc_url( is_string( $raw_edit_link ) ? $raw_edit_link : '' ), __( 'Edit', 'robotstxt-mediaaudit' ) ), 'view_details' => sprintf( '%s', $id, __( 'View Details', 'robotstxt-mediaaudit' ) ), 'full_report' => sprintf( '%s', esc_url( AttachmentDetailPage::url( $id ) ), __( 'Full Report', 'robotstxt-mediaaudit' ) ), ); return $title . $this->row_actions( $actions ); } /** * Renders the attachment ID column. * * @param array $item Row data. * * @return string */ protected function column_attachment_id( $item ) { $aid_val = $item['attachment_id']; return esc_html( (string) ( is_numeric( $aid_val ) ? (int) $aid_val : 0 ) ); } /** * Renders the usage count column. * * @param array $item Row data. * * @return string */ protected function column_usage_count( $item ) { $cnt_val = $item['usage_count']; $count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0; if ( 0 === $count ) { return '' . esc_html__( 'Unused', 'robotstxt-mediaaudit' ) . ''; } return esc_html( (string) $count ); } /** * Renders the usages column with inline post links (max 3) and a modal link for more. * * @param array $item Row data. * * @return string */ protected function column_usages( $item ) { $aid_val = $item['attachment_id']; $cnt_val = $item['usage_count']; $id = is_numeric( $aid_val ) ? (int) $aid_val : 0; $count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0; if ( 0 === $count ) { return '—'; } $usages = isset( $item['usages_data'] ) && is_array( $item['usages_data'] ) ? $item['usages_data'] : array(); $shown = array_slice( $usages, 0, 3 ); $html = '
    '; foreach ( $shown as $u ) { if ( ! is_array( $u ) ) { continue; } $pid_raw = $u['post_id'] ?? 0; $post_id = is_numeric( $pid_raw ) ? (int) $pid_raw : 0; $t = $u['post_title'] ?? ''; $raw_title = is_string( $t ) ? $t : ''; $post_title = esc_html( '' !== $raw_title ? $raw_title : __( '(no title)', 'robotstxt-mediaaudit' ) ); $edit_link = get_edit_post_link( $post_id ); if ( $edit_link ) { $html .= sprintf( '
  • %s
  • ', esc_url( $edit_link ), $post_title ); } else { $html .= '
  • ' . $post_title . '
  • '; } } if ( $count > 3 ) { $html .= sprintf( '
  • %s
  • ', $id, sprintf( /* translators: %d: number of additional usages */ esc_html__( '+ %d more…', 'robotstxt-mediaaudit' ), $count - 3 ) ); } $html .= '
'; return $html; } /** * Renders the external status badge. * * @param array $item Row data. * * @return string */ protected function column_external_status( $item ) { $es_val = $item['external_status']; $status = is_string( $es_val ) ? $es_val : ''; // Check for alert domains first. $top_domains_raw = isset( $item['top_domains_data'] ) && is_array( $item['top_domains_data'] ) ? $item['top_domains_data'] : array(); $top_domains_data = array(); foreach ( $top_domains_raw as $td_key => $td_val ) { if ( is_string( $td_key ) && is_int( $td_val ) ) { $top_domains_data[ $td_key ] = $td_val; } } $has_alert = ! empty( $top_domains_data ) && HostnameFilter::has_alert_domains( $top_domains_data ); if ( $has_alert ) { $out = sprintf( '%s', esc_html__( 'Alert', 'robotstxt-mediaaudit' ) ); // Sum counts for non-ignored domains. $filtered_count = 0; foreach ( $top_domains_data as $domain => $count ) { if ( ! HostnameFilter::is_ignored( $domain ) ) { $filtered_count += $count; } } if ( $filtered_count > 0 ) { $out .= sprintf( ' %s', esc_html( number_format_i18n( $filtered_count ) ) ); } $aid_val = $item['attachment_id']; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $out .= sprintf( ' %s', $aid, esc_html__( 'View Results', 'robotstxt-mediaaudit' ) ); return $out; } $labels = array( 'pending' => __( 'Pending', 'robotstxt-mediaaudit' ), 'queued' => __( 'Queued', 'robotstxt-mediaaudit' ), 'scanned' => __( 'Scanned', 'robotstxt-mediaaudit' ), 'matches' => __( 'Matches Found', 'robotstxt-mediaaudit' ), 'error' => __( 'Error', 'robotstxt-mediaaudit' ), ); $label = isset( $labels[ $status ] ) ? $labels[ $status ] : ucfirst( $status ); $out = sprintf( '%s', esc_attr( $status ), esc_html( $label ) ); if ( 'matches' === $status ) { $mc_val = $item['total_match_count'] ?? null; $match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0; if ( $match_count > 0 ) { $out .= sprintf( ' %s', esc_html( number_format_i18n( $match_count ) ) ); } } if ( in_array( $status, array( 'scanned', 'matches' ), true ) ) { $aid_val = $item['attachment_id']; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $out .= sprintf( ' %s', $aid, esc_html__( 'View Results', 'robotstxt-mediaaudit' ) ); } return $out; } /** * Renders the external scan date column. * * Shows a human-readable time difference (e.g. "3 days ago") with the full * date/time as a tooltip. Displays "—" when the attachment has never been scanned. * * @param array $item Row data. * * @return string */ protected function column_external_scanned_at( $item ) { $raw = $item['external_scanned_at']; if ( ! is_string( $raw ) || '' === $raw ) { return '—'; } $ts = strtotime( $raw ); if ( false === $ts ) { return '—'; } $date_fmt = get_option( 'date_format' ); $time_fmt = get_option( 'time_format' ); $format = ( is_string( $date_fmt ) ? $date_fmt : 'Y-m-d' ) . ' ' . ( is_string( $time_fmt ) ? $time_fmt : 'H:i' ); $full_val = wp_date( $format, $ts ); $full = is_string( $full_val ) ? $full_val : $raw; return sprintf( '%s', esc_attr( $full ), sprintf( /* translators: %s: human-readable time difference, e.g. "3 days" */ esc_html__( '%s ago', 'robotstxt-mediaaudit' ), esc_html( human_time_diff( $ts ) ) ) ); } /** * Renders filter controls above the table. * * @param string $which 'top' or 'bottom'. * * @return void */ protected function extra_tablenav( $which ) { if ( 'top' !== $which ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended $current_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : ''; $current_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : ''; $show_unused = ! empty( $_REQUEST['mra_unused'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $post_types = self::get_used_post_types(); echo '
'; // External status filter. echo ''; // Post type filter. if ( $post_types ) { echo ''; } // Unused toggle. echo ''; submit_button( __( 'Filter', 'robotstxt-mediaaudit' ), 'button', 'mra_filter', false ); echo '
'; } /** * Message shown when the index is empty. * * @return void */ public function no_items() { esc_html_e( 'No indexed attachments found. Run wp mra scan-internal to populate the index.', 'robotstxt-mediaaudit' ); } /** * Queries the database and sets $this->items. * * @return void */ public function prepare_items() { global $wpdb; $per_page = 20; $paged = $this->get_pagenum(); $offset = ( $paged - 1 ) * $per_page; // Sorting. $allowed_orderby = array( 'attachment_id', 'usage_count', 'external_scanned_at' ); // phpcs:disable WordPress.Security.NonceVerification.Recommended $orderby_raw = isset( $_REQUEST['orderby'] ) && is_string( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : ''; $orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'attachment_id'; $order_raw = strtoupper( isset( $_REQUEST['order'] ) && is_string( $_REQUEST['order'] ) ? sanitize_key( wp_unslash( $_REQUEST['order'] ) ) : '' ); $order = ( 'DESC' === $order_raw ) ? 'DESC' : 'ASC'; // Filters. $search = isset( $_REQUEST['s'] ) && is_string( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; $external_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : ''; $filter_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : ''; $show_unused = ! empty( $_REQUEST['mra_unused'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended // Detect alert filter before normalising external_status. $filter_alert = ( 'alert' === $external_status ); if ( $filter_alert ) { // Reuse existing matches WHERE logic to narrow the candidate set. $external_status = 'matches'; } if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) { $external_status = ''; } // Build WHERE clause. $where_parts = array( '1=1' ); $prepare_args = array(); if ( '' !== $search ) { $where_parts[] = 'i.file_name LIKE %s'; $prepare_args[] = '%' . $wpdb->esc_like( $search ) . '%'; } if ( '' !== $external_status ) { $where_parts[] = 'i.external_status = %s'; $prepare_args[] = $external_status; } if ( '' !== $filter_post_type ) { $where_parts[] = 'EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id AND mu.post_type = %s)'; $prepare_args[] = $filter_post_type; } if ( $show_unused ) { $where_parts[] = 'NOT EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id)'; } $where = implode( ' AND ', $where_parts ); if ( 'usage_count' === $orderby ) { $order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}"; } elseif ( 'external_scanned_at' === $orderby ) { // NULLs always last, regardless of sort direction. $order_sql = "i.external_scanned_at IS NULL ASC, i.external_scanned_at {$order}"; } else { $order_sql = "i.attachment_id {$order}"; } $select_sql = "SELECT i.*, (SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) AS usage_count, (SELECT COALESCE(SUM(er.match_count), 0) FROM {$wpdb->prefix}mra_external_results er WHERE er.attachment_id = i.attachment_id) AS total_match_count FROM {$wpdb->prefix}mra_media_index i"; $rows = array(); $total = 0; if ( $filter_alert ) { // Fetch ALL matching rows (no LIMIT), then PHP-filter by alert domains. $all_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql}"; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $all_prepared = empty( $prepare_args ) ? $all_sql : $wpdb->prepare( $all_sql, ...$prepare_args ); $all_rows = $wpdb->get_results( $all_prepared, ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared if ( ! is_array( $all_rows ) ) { $all_rows = array(); } // Bulk-fetch top_domains for all candidate rows. $all_ids = array_map( 'intval', array_column( $all_rows, 'attachment_id' ) ); $top_domains_by_id = self::fetch_top_domains( $all_ids ); // PHP-filter: keep only rows that have at least one alert domain. $filtered = array(); foreach ( $all_rows as $r ) { if ( ! is_array( $r ) ) { continue; } $aid_val = $r['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; if ( HostnameFilter::has_alert_domains( $top_domains_by_id[ $aid ] ?? array() ) ) { $filtered[] = $r; } } $total = count( $filtered ); $rows = array_slice( $filtered, $offset, $per_page ); // Attach top_domains_data for the page slice. foreach ( $rows as &$row ) { $rid_val = $row['attachment_id'] ?? null; $rid = is_numeric( $rid_val ) ? (int) $rid_val : 0; $row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array(); } unset( $row ); } else { $count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}"; $items_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql} LIMIT %d OFFSET %d"; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $count_prepared = empty( $prepare_args ) ? $count_sql : $wpdb->prepare( $count_sql, ...$prepare_args ); $items_prepared = $wpdb->prepare( $items_sql, ...array_merge( $prepare_args, array( $per_page, $offset ) ) ); $total = (int) $wpdb->get_var( $count_prepared ); $rows = $wpdb->get_results( $items_prepared, ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared if ( ! is_array( $rows ) ) { $rows = array(); } // Bulk-fetch top_domains only for rows with external_status = 'matches'. $matches_ids = array(); foreach ( $rows as $r ) { if ( ! is_array( $r ) ) { continue; } $es_val = $r['external_status'] ?? null; if ( 'matches' === $es_val ) { $aid_val = $r['attachment_id'] ?? null; if ( is_numeric( $aid_val ) ) { $matches_ids[] = (int) $aid_val; } } } $top_domains_by_id = empty( $matches_ids ) ? array() : self::fetch_top_domains( $matches_ids ); foreach ( $rows as &$row ) { if ( ! is_array( $row ) ) { continue; } $rid_val = $row['attachment_id'] ?? null; $rid = is_numeric( $rid_val ) ? (int) $rid_val : 0; $row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array(); } unset( $row ); } if ( empty( $rows ) ) { $this->items = array(); } else { // Bulk-load usages for this page (avoid N+1). $ids = array_map( 'intval', array_column( $rows, 'attachment_id' ) ); $usages = self::fetch_usages( $ids ); $usage_by = array(); foreach ( $usages as $u ) { $uid_val = $u['attachment_id'] ?? null; $uid = is_numeric( $uid_val ) ? (int) $uid_val : 0; $usage_by[ $uid ][] = $u; } foreach ( $rows as &$row ) { if ( ! is_array( $row ) ) { continue; } $rid_val = $row['attachment_id'] ?? null; $rid = is_numeric( $rid_val ) ? (int) $rid_val : 0; $row['usages_data'] = $usage_by[ $rid ] ?? array(); } unset( $row ); $this->items = $rows; } $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'filename', ); $this->set_pagination_args( array( 'total_items' => $total, 'total_pages' => (int) ceil( $total / $per_page ), 'per_page' => $per_page, ) ); } // ------------------------------------------------------------------------- // Public query helpers // ------------------------------------------------------------------------- /** * Fetches and merges top_domains for a set of attachment IDs. * Multiple providers for the same attachment have their domain counts summed. * * @param array $attachment_ids Attachment IDs to look up. * * @return array> attachment_id → domain → count */ public static function fetch_top_domains( array $attachment_ids ): array { global $wpdb; if ( empty( $attachment_ids ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $rows = $wpdb->get_results( $wpdb->prepare( "SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})", ...$attachment_ids ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare if ( ! is_array( $rows ) ) { return array(); } $result = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $aid_val = $row['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; if ( $aid <= 0 ) { continue; } $td_val = $row['top_domains'] ?? null; $td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null; $domains = is_array( $td_raw ) ? $td_raw : array(); if ( ! isset( $result[ $aid ] ) ) { $result[ $aid ] = array(); } foreach ( $domains as $domain => $count ) { if ( ! is_string( $domain ) ) { continue; } $c = is_numeric( $count ) ? (int) $count : 0; if ( isset( $result[ $aid ][ $domain ] ) ) { $result[ $aid ][ $domain ] += $c; } else { $result[ $aid ][ $domain ] = $c; } } } return $result; } /** * Fetches usage rows for a set of attachment IDs in one query. * * @param array $attachment_ids Attachment IDs to look up. * * @return list> */ public static function fetch_usages( array $attachment_ids ): array { global $wpdb; if ( empty( $attachment_ids ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.attachment_id, u.post_id, u.post_type, u.context, u.meta_key, p.post_title, p.post_status FROM {$wpdb->prefix}mra_media_usage u JOIN {$wpdb->posts} p ON u.post_id = p.ID WHERE u.attachment_id IN ({$placeholders}) ORDER BY u.attachment_id ASC, u.post_id ASC", ...$attachment_ids ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare return $rows ?? array(); } /** * Returns distinct post types that have at least one usage record. * * @return array */ public static function get_used_post_types(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_col( "SELECT DISTINCT post_type FROM {$wpdb->prefix}mra_media_usage ORDER BY post_type ASC" ); if ( ! is_array( $rows ) ) { return array(); } $types = array(); foreach ( $rows as $r ) { if ( is_string( $r ) ) { $types[] = $r; } } return $types; } }