← 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 | ], |