PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | media-optimization/class-media-library-list-table.php +132 -33 2.6.21 → 2.7.2 View file →
@@ -17,9 +17,9 @@
17 17 }
18 18
19 19 class Metasync_Media_Library_List_Table extends WP_List_Table {
20 20
21 - private const PER_PAGE = 20;
21 + private const DEFAULT_PER_PAGE = 20;
22 22 private const PAGED_PARAM = 'paged_media';
23 23 private const ORDERBY_PARAM = 'orderby_media';
24 24 private const ORDER_PARAM = 'order_media';
25 25
@@ -49,8 +49,17 @@
49 49 * @var array<int, int[]>
50 50 */
51 51 private array $attachment_posts = [];
52 52
53 + /**
54 + * User-resolved results-per-page value for this list (validated against
55 + * Metasync_Per_Page_Helper::ALLOWED_VALUES). Defaults to 20 for
56 + * backwards compatibility; updated in prepare_items().
57 + *
58 + * @var int
59 + */
60 + private int $resolved_per_page = 20;
61 +
53 62 public function __construct() {
54 63 parent::__construct([
55 64 'singular' => 'image',
56 65 'plural' => 'images',
@@ -206,8 +215,20 @@
206 215
207 216 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
208 217
209 218 if (!$format) {
219 + // Natively-uploaded WebP/AVIF is already optimized and cannot be
220 + // converted — the stats card counts these as optimized, so the
221 + // row badge must agree instead of showing "Unoptimized" for the
222 + // same image on the same screen.
223 + $mime = get_post_mime_type($attachment_id);
224 + if (in_array($mime, self::OPTIMIZED_MIME_TYPES, true)) {
225 + return sprintf(
226 + '<span class="metasync-status-badge metasync-status-optimized">%s</span>',
227 + esc_html__('Optimized (native)', 'metasync')
228 + );
229 + }
230 +
210 231 $file = get_attached_file($attachment_id);
211 232 $size = ($file && file_exists($file)) ? size_format((int) filesize($file), 1) : '';
212 233 $size_html = $size ? sprintf(
213 234 '<div class="metasync-size-info"><span class="metasync-size-detail">%s</span></div>',
@@ -361,9 +382,9 @@
361 382 if ($which !== 'top') {
362 383 return;
363 384 }
364 385
365 - $current_status = isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '';
386 + $current_status = isset($_REQUEST['status_filter']) ? sanitize_text_field(wp_unslash($_REQUEST['status_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
366 387 ?>
367 388 <div class="alignleft actions">
368 389 <select name="status_filter">
369 390 <option value=""><?php esc_html_e('All Images', 'metasync'); ?></option>
@@ -388,8 +409,11 @@
388 409 [],
389 410 $this->get_sortable_columns(),
390 411 ];
391 412
413 + $per_page = Metasync_Per_Page_Helper::resolve('media_library', self::DEFAULT_PER_PAGE);
414 + $this->resolved_per_page = $per_page;
415 +
392 416 $current_page = $this->get_pagenum();
393 417 $filters = $this->get_filters();
394 418
395 419 $query_args = [
@@ -395,9 +419,9 @@
395 419 $query_args = [
396 420 'post_type' => 'attachment',
397 421 'post_status' => 'inherit',
398 422 'post_mime_type' => self::IMAGE_MIME_TYPES,
399 - 'posts_per_page' => self::PER_PAGE,
423 + 'posts_per_page' => $per_page,
400 424 'paged' => $current_page,
401 425 'orderby' => $filters['orderby'],
402 426 'order' => $filters['order'],
403 427 ];
@@ -466,9 +490,9 @@
466 490 $this->resolve_attachment_posts();
467 491
468 492 $this->set_pagination_args([
469 493 'total_items' => $query->found_posts,
470 - 'per_page' => self::PER_PAGE,
494 + 'per_page' => $per_page,
471 495 'total_pages' => $query->max_num_pages,
472 496 ]);
473 497 }
474 498
@@ -475,9 +499,9 @@
475 499 /**
476 500 * Override get_pagenum to use custom paged param.
477 501 */
478 502 public function get_pagenum(): int {
479 - $pagenum = isset($_REQUEST[self::PAGED_PARAM]) ? absint($_REQUEST[self::PAGED_PARAM]) : 0;
503 + $pagenum = isset($_REQUEST[self::PAGED_PARAM]) ? absint($_REQUEST[self::PAGED_PARAM]) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
480 504
481 505 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
482 506 $pagenum = $this->_pagination_args['total_pages'];
483 507 }
@@ -496,17 +520,26 @@
496 520 $total_items = (int) $this->_pagination_args['total_items'];
497 521 $total_pages = (int) $this->_pagination_args['total_pages'];
498 522 $current = $this->get_pagenum();
499 523
500 - if ($total_pages <= 1) {
501 - return;
502 - }
503 -
504 524 $output = '<span class="displaying-num">' . sprintf(
525 + /* translators: %s: number of items. */
505 526 _n('%s item', '%s items', $total_items, 'metasync'),
506 527 number_format_i18n($total_items)
507 528 ) . '</span>';
508 529
530 + // Inline "rows per page" selector (10/20/50/100), persisted per user.
531 + // Rendered unconditionally so the control is always available, even
532 + // when there is only a single page of results. $which keeps the top and
533 + // bottom selectors' ids distinct.
534 + $output .= Metasync_Per_Page_Helper::render_selector('media_library', $this->resolved_per_page, $which);
535 +
536 + if ($total_pages <= 1) {
537 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- selector markup escaped inside Metasync_Per_Page_Helper::render_selector() (esc_attr / esc_html / %d)
538 + echo "<div class='tablenav-pages one-page'>$output</div>";
539 + return;
540 + }
541 +
509 542 $removable_args = [
510 543 'paged', 'paged_redir', 'paged_404',
511 544 '_wpnonce', '_wp_http_referer',
512 545 'action', 'action2',
@@ -574,8 +607,9 @@
574 607 $output .= "\n<span class='$pagination_links_class'>" .
575 608 $first_link . $prev_link .
576 609 '<span class="paging-input">' .
577 610 sprintf(
611 + /* translators: 1: current page number, 2: total number of pages. */
578 612 _x('%1$s of %2$s', 'paging', 'metasync'),
579 613 $html_current_page,
580 614 $html_total_pages
581 615 ) .
@@ -582,8 +616,9 @@
582 616 '</span>' .
583 617 $next_link . $last_link .
584 618 '</span>';
585 619
620 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::pagination(); link hrefs escaped via esc_url(), counts via number_format_i18n()
586 621 echo "<div class='tablenav-pages'>$output</div>";
587 622 }
588 623
589 624 /**
@@ -594,10 +629,10 @@
594 629
595 630 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
596 631 $current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
597 632
598 - $current_orderby = isset($_GET[self::ORDERBY_PARAM]) ? sanitize_text_field($_GET[self::ORDERBY_PARAM]) : '';
599 - $current_order = isset($_GET[self::ORDER_PARAM]) ? sanitize_text_field($_GET[self::ORDER_PARAM]) : 'asc';
633 + $current_orderby = isset($_GET[self::ORDERBY_PARAM]) ? sanitize_text_field(wp_unslash($_GET[self::ORDERBY_PARAM])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
634 + $current_order = isset($_GET[self::ORDER_PARAM]) ? sanitize_text_field(wp_unslash($_GET[self::ORDER_PARAM])) : 'asc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
600 635
601 636 foreach ($columns as $column_key => $column_display_name) {
602 637 $class = ['manage-column', "column-$column_key"];
603 638
@@ -640,8 +675,9 @@
640 675
641 676 $tag = ($column_key === 'cb') ? 'td' : 'th';
642 677 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
643 678
679 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- mirrors WP core WP_List_Table::print_column_headers(); $id/$class escaped via esc_attr() just above, \$column_display_name via esc_html()
644 680 echo '<' . esc_attr($tag) . ' ' . $id . " class='" . esc_attr(implode(' ', $class)) . "' " . esc_attr($attr) . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
645 681 }
646 682 }
647 683
@@ -661,11 +697,9 @@
661 697 }
662 698
663 699 global $wpdb;
664 700
665 - $ids = wp_list_pluck($this->items, 'ID');
666 - $id_set = implode(',', array_map('intval', $ids));
667 - $valid_statuses = "'publish','draft','private','pending','future'";
701 + $ids = array_map('intval', wp_list_pluck($this->items, 'ID'));
668 702
669 703 // 1. post_parent relationships
670 704 foreach ($this->items as $item) {
671 705 if (!empty($item->post_parent)) {
@@ -676,17 +710,19 @@
676 710 }
677 711 }
678 712
679 713 // 2. Featured image relationships (_thumbnail_id meta)
680 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
681 - $featured_rows = $wpdb->get_results(
682 - "SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
683 - FROM {$wpdb->postmeta} pm
684 - INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
685 - WHERE pm.meta_key = '_thumbnail_id'
686 - AND pm.meta_value IN ({$id_set})
687 - AND p.post_status IN ({$valid_statuses})
688 - AND p.post_type NOT IN ('attachment','revision')"
714 + $featured_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin list-table joins over posts+postmeta — aggregate view, no WordPress API for the join
715 + $wpdb->prepare(
716 + "SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
717 + FROM {$wpdb->postmeta} pm
718 + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
719 + WHERE pm.meta_key = '_thumbnail_id'
720 + AND pm.meta_value IN (" . implode(',', array_fill(0, count($ids), '%d')) . ")
721 + AND p.post_status IN ('publish','draft','private','pending','future')
722 + AND p.post_type NOT IN ('attachment','revision')",
723 + $ids
724 + )
689 725 );
690 726
691 727 foreach ($featured_rows as $row) {
692 728 $att_id = (int) $row->attachment_id;
@@ -729,16 +765,20 @@
729 765 // Deduplicate clauses to avoid redundant OR conditions
730 766 $like_clauses = array_unique($like_clauses);
731 767 $like_sql = implode(' OR ', $like_clauses);
732 768
733 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
734 - $content_rows = $wpdb->get_results(
769 + // Each LIKE fragment is itself prepare()'d above; the status list
770 + // is literal. Only the OR-joined fragment list is dynamic, and every
771 + // fragment in it carries its own bound value.
772 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- $like_sql is an OR-join of prepare()'d fragments built above
773 + $content_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin list-table joins over posts+postmeta — aggregate view, no WordPress API for the join
735 774 "SELECT p.ID AS post_id, p.post_content
736 775 FROM {$wpdb->posts} p
737 776 WHERE ({$like_sql})
738 777 AND p.post_type NOT IN ('attachment','revision')
739 - AND p.post_status IN ({$valid_statuses})"
778 + AND p.post_status IN ('publish','draft','private','pending','future')"
740 779 );
780 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
741 781
742 782 foreach ($content_rows as $row) {
743 783 $post_id = (int) $row->post_id;
744 784 foreach ($search_map as $att_id => $tokens) {
@@ -759,12 +799,12 @@
759 799 * Get sanitized filter values from request.
760 800 */
761 801 private function get_filters(): array {
762 802 return [
763 - 'search' => isset($_REQUEST['s']) ? sanitize_text_field($_REQUEST['s']) : '',
764 - 'status' => isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '',
765 - 'orderby' => isset($_REQUEST[self::ORDERBY_PARAM]) ? sanitize_sql_orderby($_REQUEST[self::ORDERBY_PARAM]) ?: 'date' : 'date',
766 - 'order' => isset($_REQUEST[self::ORDER_PARAM]) && strtolower($_REQUEST[self::ORDER_PARAM]) === 'asc' ? 'ASC' : 'DESC',
803 + 'search' => isset($_REQUEST['s']) ? sanitize_text_field(wp_unslash($_REQUEST['s'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
804 + 'status' => isset($_REQUEST['status_filter']) ? sanitize_text_field(wp_unslash($_REQUEST['status_filter'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
805 + 'orderby' => isset($_REQUEST[self::ORDERBY_PARAM]) ? sanitize_sql_orderby($_REQUEST[self::ORDERBY_PARAM]) ?: 'date' : 'date', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
806 + 'order' => isset($_REQUEST[self::ORDER_PARAM]) && strtolower($_REQUEST[self::ORDER_PARAM]) === 'asc' ? 'ASC' : 'DESC', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
767 807 ];
768 808 }
769 809
770 810 /**
@@ -775,13 +815,72 @@
775 815 */
776 816 private const OPTIMIZED_MIME_TYPES = ['image/webp', 'image/avif'];
777 817
778 818 public static function get_stats(): array {
819 + // While a batch is running the browser polls every 500ms and every
820 + // tick is a fresh PHP process, so library-wide scans cannot amortize.
821 + // Derive the live numbers from the batch's own baseline + progress
822 + // counters instead — zero queries per tick.
823 + if (Metasync_Media_Batch_Optimizer::is_running()) {
824 + $derived = self::derive_stats_from_batch();
825 + if ($derived !== null) {
826 + return $derived;
827 + }
828 + }
829 +
830 + // Outside a batch, cache the three scans: the numbers only move on
831 + // conversion, revert, or attachment deletion, each of which
832 + // invalidates the transient.
833 + $cached = get_transient(Metasync_Media_Batch_Optimizer::STATS_TRANSIENT);
834 + if (is_array($cached) && isset($cached['total'], $cached['optimized'])) {
835 + return $cached;
836 + }
837 +
838 + $stats = self::query_stats();
839 + set_transient(Metasync_Media_Batch_Optimizer::STATS_TRANSIENT, $stats, 15 * MINUTE_IN_SECONDS);
840 +
841 + return $stats;
842 + }
843 +
844 + /**
845 + * Live stats for a running batch: its start-of-run baseline plus the
846 + * batch's own progress counters. Every queued image was unoptimized at
847 + * queue-build time, so each non-failed conversion moves exactly one
848 + * image across.
849 + */
850 + private static function derive_stats_from_batch(): ?array {
851 + $baseline = get_option(Metasync_Media_Batch_Optimizer::BASELINE_STATS_OPTION, []);
852 + if (!is_array($baseline) || !isset($baseline['total'], $baseline['optimized'])) {
853 + return null;
854 + }
855 +
856 + $progress = Metasync_Media_Batch_Optimizer::get_progress();
857 + $optimized = (int) $baseline['optimized'] + (int) $progress['processed'] - (int) $progress['failed'];
858 + $total = (int) $baseline['total'];
859 +
860 + return [
861 + 'total' => $total,
862 + 'optimized' => $optimized,
863 + 'unoptimized' => max(0, $total - $optimized),
864 + 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
865 + ];
866 + }
867 +
868 + /**
869 + * The uncached library-wide scans. Called at most once per 15 minutes
870 + * outside a batch, never during one.
871 + *
872 + * Every scan is count-only: posts_per_page 1 keeps the row fetch at a
873 + * single ID while found_rows still reports the full matching count, so a
874 + * cache miss no longer transfers and allocates the whole attachment
875 + * library's IDs three times over.
876 + */
877 + private static function query_stats(): array {
779 878 $total = (new WP_Query([
780 879 'post_type' => 'attachment',
781 880 'post_status' => 'inherit',
782 881 'post_mime_type' => self::IMAGE_MIME_TYPES,
783 - 'posts_per_page' => -1,
882 + 'posts_per_page' => 1,
784 883 'fields' => 'ids',
785 884 ]))->found_posts;
786 885
787 886 $converted = (new WP_Query([
@@ -787,9 +886,9 @@
787 886 $converted = (new WP_Query([
788 887 'post_type' => 'attachment',
789 888 'post_status' => 'inherit',
790 889 'post_mime_type' => self::IMAGE_MIME_TYPES,
791 - 'posts_per_page' => -1,
890 + 'posts_per_page' => 1,
792 891 'fields' => 'ids',
793 892 'meta_query' => [
794 893 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
795 894 ],
@@ -800,9 +899,9 @@
800 899 $natively_optimized = (new WP_Query([
801 900 'post_type' => 'attachment',
802 901 'post_status' => 'inherit',
803 902 'post_mime_type' => self::OPTIMIZED_MIME_TYPES,
804 - 'posts_per_page' => -1,
903 + 'posts_per_page' => 1,
805 904 'fields' => 'ids',
806 905 'meta_query' => [
807 906 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
808 907 ],