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
metasync / media-optimization / class-media-library-list-table.php

class-media-library-list-table.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at media-optimization/class-media-library-list-table.php

940 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Media_Library_List_Table
4 * WP_List_Table for displaying media library images with optimization status.
5 *
6 * @package Search Atlas SEO
7 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
8 * @since 2.6.0
9 */
10
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 if (!class_exists('WP_List_Table')) {
16 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
17 }
18
19 class Metasync_Media_Library_List_Table extends WP_List_Table {
20
21 private const DEFAULT_PER_PAGE = 20;
22 private const PAGED_PARAM = 'paged_media';
23 private const ORDERBY_PARAM = 'orderby_media';
24 private const ORDER_PARAM = 'order_media';
25
26 /**
27 * All image MIME types displayed in the library (includes next-gen formats
28 * so that images converted with the "replace" strategy remain visible).
29 */
30 private const IMAGE_MIME_TYPES = [
31 'image/jpeg',
32 'image/png',
33 'image/webp',
34 'image/avif',
35 ];
36
37 /**
38 * MIME types eligible for optimization (source formats only).
39 */
40 private const OPTIMIZABLE_MIME_TYPES = [
41 'image/jpeg',
42 'image/png',
43 ];
44
45 /**
46 * Resolved attachment-to-post relationships for the current page.
47 * Keys are attachment IDs, values are arrays of post IDs.
48 *
49 * @var array<int, int[]>
50 */
51 private array $attachment_posts = [];
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
62 public function __construct() {
63 parent::__construct([
64 'singular' => 'image',
65 'plural' => 'images',
66 'ajax' => false,
67 ]);
68 }
69
70 /**
71 * Get table columns.
72 */
73 public function get_columns(): array {
74 return [
75 'cb' => '<input type="checkbox" />',
76 'image' => __('Image', 'metasync'),
77 'post' => __('Post', 'metasync'),
78 'status' => __('Status', 'metasync'),
79 'actions' => __('Actions', 'metasync'),
80 ];
81 }
82
83 /**
84 * Get sortable columns.
85 */
86 protected function get_sortable_columns(): array {
87 return [
88 'image' => ['title', false],
89 ];
90 }
91
92 /**
93 * Checkbox column for bulk actions.
94 */
95 protected function column_cb($item): string {
96 return sprintf(
97 '<input type="checkbox" name="image_ids[]" value="%d" />',
98 $item->ID
99 );
100 }
101
102 /**
103 * Whether the attachment's primary file is missing on disk.
104 *
105 * Orphaned attachments (DB record present, file deleted externally or via a
106 * failed sideload) otherwise render broken thumbnails and can never be
107 * optimized. Used to flag these rows distinctly across the table.
108 */
109 public static function is_file_missing(int $attachment_id): bool {
110 $path = get_attached_file($attachment_id);
111 return !$path || !file_exists($path);
112 }
113
114 /**
115 * Image column: thumbnail + filename + URL with copy button.
116 */
117 protected function column_image($item): string {
118 if (self::is_file_missing($item->ID)) {
119 $thumb = sprintf(
120 '<span class="metasync-image-thumb-missing" title="%s"><span class="dashicons dashicons-format-image"></span></span>',
121 esc_attr__('File missing on disk', 'metasync')
122 );
123 } else {
124 $thumb = wp_get_attachment_image($item->ID, [50, 50], true, ['style' => 'border-radius:4px;']);
125 }
126 $url = wp_get_attachment_url($item->ID);
127 $file = basename(get_attached_file($item->ID) ?: '');
128
129 return sprintf(
130 '<div class="metasync-image-cell">
131 <div class="metasync-image-thumb">%s</div>
132 <div class="metasync-image-info">
133 <strong class="metasync-image-filename">%s</strong>
134 <span class="metasync-image-url">
135 <a href="%s" target="_blank" title="%s">%s</a>
136 <button type="button" class="metasync-copy-btn" data-url="%s" title="%s">
137 <span class="dashicons dashicons-clipboard"></span>
138 </button>
139 </span>
140 </div>
141 </div>',
142 $thumb,
143 esc_html($file),
144 esc_url($url),
145 esc_attr($url),
146 esc_html($this->truncate_url($url)),
147 esc_attr($url),
148 esc_attr__('Copy URL', 'metasync')
149 );
150 }
151
152 /**
153 * Post column: linked posts or "Unattached".
154 *
155 * Shows all posts that use this attachment (via post_parent, featured image, or content embedding).
156 */
157 protected function column_post($item): string {
158 $post_ids = $this->attachment_posts[$item->ID] ?? [];
159
160 if (empty($post_ids)) {
161 return '<span class="metasync-text-muted">' . esc_html__('Unattached', 'metasync') . '</span>';
162 }
163
164 $links = [];
165 foreach ($post_ids as $post_id) {
166 $post = get_post($post_id);
167 if (!$post) {
168 continue;
169 }
170
171 $permalink = get_permalink($post->ID);
172 $title = $post->post_title ?: __('(no title)', 'metasync');
173
174 $links[] = sprintf(
175 '<div class="metasync-post-link-row">
176 <a href="%s" target="_blank" title="%s">%s</a>
177 <button type="button" class="metasync-copy-btn" data-url="%s" title="%s">
178 <span class="dashicons dashicons-clipboard"></span>
179 </button>
180 </div>',
181 esc_url($permalink),
182 esc_attr($title),
183 esc_html($this->truncate_text($title, 40)),
184 esc_attr($permalink),
185 esc_attr__('Copy post URL', 'metasync')
186 );
187 }
188
189 if (empty($links)) {
190 return '<span class="metasync-text-muted">' . esc_html__('Unattached', 'metasync') . '</span>';
191 }
192
193 return implode('', $links);
194 }
195
196 /**
197 * Status column: optimized badge with savings or unoptimized.
198 */
199 protected function column_status($item): string {
200 return self::render_status_html($item->ID);
201 }
202
203 /**
204 * Render status HTML for a given attachment.
205 * Reusable by AJAX handlers (DRY).
206 */
207 public static function render_status_html(int $attachment_id): string {
208 // Orphaned attachment: file no longer exists on disk.
209 if (self::is_file_missing($attachment_id)) {
210 return sprintf(
211 '<span class="metasync-status-badge metasync-status-missing">%s</span>',
212 esc_html__('Missing file', 'metasync')
213 );
214 }
215
216 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
217
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
231 $file = get_attached_file($attachment_id);
232 $size = ($file && file_exists($file)) ? size_format((int) filesize($file), 1) : '';
233 $size_html = $size ? sprintf(
234 '<div class="metasync-size-info"><span class="metasync-size-detail">%s</span></div>',
235 esc_html($size)
236 ) : '';
237
238 return sprintf(
239 '<span class="metasync-status-badge metasync-status-unoptimized">%s</span>%s',
240 esc_html__('Unoptimized', 'metasync'),
241 $size_html
242 );
243 }
244
245 $badge = sprintf(
246 '<span class="metasync-status-badge metasync-status-optimized">%s (%s)</span>',
247 esc_html__('Optimized', 'metasync'),
248 esc_html(strtoupper($format))
249 );
250
251 $savings = self::get_size_savings($attachment_id);
252 if (!$savings) {
253 return $badge;
254 }
255
256 return $badge . sprintf(
257 '<div class="metasync-size-savings">'
258 . '<span class="metasync-savings-pct">&darr; %s%%</span>'
259 . '<span class="metasync-savings-detail">%s &rarr; %s</span>'
260 . '</div>',
261 esc_html($savings['percentage']),
262 esc_html($savings['original_human']),
263 esc_html($savings['converted_human'])
264 );
265 }
266
267 /**
268 * Calculate size savings for an optimized attachment.
269 */
270 private static function get_size_savings(int $attachment_id): ?array { // phpcs:ignore WordPress.NamingConventions
271 $file = get_attached_file($attachment_id);
272 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
273
274 if (!$file || !$format) {
275 return null;
276 }
277
278 // Original size: prefer stored meta, fall back to reading original file from disk
279 $original_size = (int) get_post_meta($attachment_id, '_metasync_original_filesize', true);
280 if (!$original_size && file_exists($file)) {
281 $original_size = (int) filesize($file);
282 // Backfill meta for future lookups
283 if ($original_size) {
284 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
285 }
286 }
287
288 if (!$original_size) {
289 return null;
290 }
291
292 $ext = $format === 'avif' ? '.avif' : '.webp';
293 $converted_path = preg_replace('/\.(jpe?g|png)$/i', $ext, $file);
294 $converted_size = ($converted_path && file_exists($converted_path)) ? (int) filesize($converted_path) : 0;
295
296 if (!$converted_size) {
297 return null;
298 }
299
300 return [
301 'percentage' => max(0, (int) round((($original_size - $converted_size) / $original_size) * 100)),
302 'original_human' => size_format($original_size, 1),
303 'converted_human' => size_format($converted_size, 1),
304 ];
305 }
306
307 /**
308 * Actions column: optimize or revert button.
309 */
310 protected function column_actions($item): string {
311 // Orphaned attachment (missing file): offer cleanup of the stale record.
312 if (self::is_file_missing($item->ID)) {
313 return sprintf(
314 '<button type="button" class="button button-small metasync-delete-orphan-btn" data-id="%d">
315 <span class="dashicons dashicons-trash" style="margin-top:3px;"></span> %s
316 </button>',
317 $item->ID,
318 esc_html__('Delete record', 'metasync')
319 );
320 }
321
322 $format = get_post_meta($item->ID, '_metasync_converted_format', true);
323
324 if ($format) {
325 if (Metasync_Image_Converter::can_revert($item->ID)) {
326 return sprintf(
327 '<button type="button" class="button button-small metasync-revert-btn" data-id="%d">
328 <span class="dashicons dashicons-undo" style="margin-top:3px;"></span> %s
329 </button>',
330 $item->ID,
331 esc_html__('Revert', 'metasync')
332 );
333 }
334
335 return sprintf(
336 '<button type="button" class="button button-small metasync-revert-btn" data-id="%d" disabled title="%s">
337 <span class="dashicons dashicons-undo" style="margin-top:3px;"></span> %s
338 </button>',
339 $item->ID,
340 esc_attr__('Original image unavailable — revert is disabled', 'metasync'),
341 esc_html__('Revert', 'metasync')
342 );
343 }
344
345 // Only show Optimize for source formats (JPEG/PNG), not for natively
346 // uploaded WebP/AVIF images that were never converted by us.
347 $mime = get_post_mime_type($item->ID);
348 if (!in_array($mime, self::OPTIMIZABLE_MIME_TYPES, true)) {
349 return '<span class="metasync-text-muted">' . esc_html__('N/A', 'metasync') . '</span>';
350 }
351
352 return sprintf(
353 '<button type="button" class="button button-small button-primary metasync-optimize-btn" data-id="%d">
354 <span class="dashicons dashicons-performance" style="margin-top:3px;"></span> %s
355 </button>',
356 $item->ID,
357 esc_html__('Optimize', 'metasync')
358 );
359 }
360
361 /**
362 * Default column handler.
363 */
364 protected function column_default($item, $column_name): string {
365 return '';
366 }
367
368 /**
369 * Bulk actions dropdown.
370 */
371 protected function get_bulk_actions(): array {
372 return [
373 'bulk_optimize' => __('Optimize Selected', 'metasync'),
374 'bulk_unoptimize' => __('Unoptimize Selected', 'metasync'),
375 ];
376 }
377
378 /**
379 * Extra controls: status filter dropdown.
380 */
381 protected function extra_tablenav($which): void {
382 if ($which !== 'top') {
383 return;
384 }
385
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
387 ?>
388 <div class="alignleft actions">
389 <select name="status_filter">
390 <option value=""><?php esc_html_e('All Images', 'metasync'); ?></option>
391 <option value="optimized" <?php selected($current_status, 'optimized'); ?>>
392 <?php esc_html_e('Optimized', 'metasync'); ?>
393 </option>
394 <option value="unoptimized" <?php selected($current_status, 'unoptimized'); ?>>
395 <?php esc_html_e('Unoptimized', 'metasync'); ?>
396 </option>
397 </select>
398 <?php submit_button(__('Filter', 'metasync'), '', 'filter_action', false); ?>
399 </div>
400 <?php
401 }
402
403 /**
404 * Prepare items for display.
405 */
406 public function prepare_items(): void {
407 $this->_column_headers = [
408 $this->get_columns(),
409 [],
410 $this->get_sortable_columns(),
411 ];
412
413 $per_page = Metasync_Per_Page_Helper::resolve('media_library', self::DEFAULT_PER_PAGE);
414 $this->resolved_per_page = $per_page;
415
416 $current_page = $this->get_pagenum();
417 $filters = $this->get_filters();
418
419 $query_args = [
420 'post_type' => 'attachment',
421 'post_status' => 'inherit',
422 'post_mime_type' => self::IMAGE_MIME_TYPES,
423 'posts_per_page' => $per_page,
424 'paged' => $current_page,
425 'orderby' => $filters['orderby'],
426 'order' => $filters['order'],
427 ];
428
429 // Use custom WHERE/JOIN filters instead of WP_Query's 's' parameter,
430 // which doesn't search _wp_attached_file meta and has quirks with
431 // post_status 'inherit' (attachments).
432 $join_cb = null;
433 $where_cb = null;
434 $distinct_cb = null;
435
436 if (!empty($filters['search'])) {
437 $search_term = $filters['search'];
438
439 $join_cb = function ($join) {
440 global $wpdb;
441 $join .= " LEFT JOIN {$wpdb->postmeta} AS mtsearch ON ({$wpdb->posts}.ID = mtsearch.post_id AND mtsearch.meta_key = '_wp_attached_file')";
442 return $join;
443 };
444
445 $where_cb = function ($where) use ($search_term) {
446 global $wpdb;
447 $like = '%' . $wpdb->esc_like($search_term) . '%';
448 $where .= $wpdb->prepare(
449 " AND ({$wpdb->posts}.post_title LIKE %s OR mtsearch.meta_value LIKE %s)",
450 $like,
451 $like
452 );
453 return $where;
454 };
455
456 $distinct_cb = function () {
457 return 'DISTINCT';
458 };
459
460 add_filter('posts_join', $join_cb);
461 add_filter('posts_where', $where_cb);
462 add_filter('posts_distinct', $distinct_cb);
463 }
464
465 if ($filters['status'] === 'optimized') {
466 $query_args['meta_query'] = [
467 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
468 ];
469 } elseif ($filters['status'] === 'unoptimized') {
470 $query_args['meta_query'] = [
471 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
472 ];
473 }
474
475 try {
476 $query = new WP_Query($query_args);
477 } finally {
478 if ($join_cb) {
479 remove_filter('posts_join', $join_cb);
480 }
481 if ($where_cb) {
482 remove_filter('posts_where', $where_cb);
483 }
484 if ($distinct_cb) {
485 remove_filter('posts_distinct', $distinct_cb);
486 }
487 }
488
489 $this->items = $query->posts;
490 $this->resolve_attachment_posts();
491
492 $this->set_pagination_args([
493 'total_items' => $query->found_posts,
494 'per_page' => $per_page,
495 'total_pages' => $query->max_num_pages,
496 ]);
497 }
498
499 /**
500 * Override get_pagenum to use custom paged param.
501 */
502 public function get_pagenum(): int {
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
504
505 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
506 $pagenum = $this->_pagination_args['total_pages'];
507 }
508
509 return max(1, $pagenum);
510 }
511
512 /**
513 * Override pagination to use custom paged param and preserve tab.
514 */
515 protected function pagination($which): void {
516 if (empty($this->_pagination_args)) {
517 return;
518 }
519
520 $total_items = (int) $this->_pagination_args['total_items'];
521 $total_pages = (int) $this->_pagination_args['total_pages'];
522 $current = $this->get_pagenum();
523
524 $output = '<span class="displaying-num">' . sprintf(
525 /* translators: %s: number of items. */
526 _n('%s item', '%s items', $total_items, 'metasync'),
527 number_format_i18n($total_items)
528 ) . '</span>';
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
542 $removable_args = [
543 'paged', 'paged_redir', 'paged_404',
544 '_wpnonce', '_wp_http_referer',
545 'action', 'action2',
546 ];
547
548 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
549 $current_url = remove_query_arg($removable_args, $current_url);
550 $current_url = add_query_arg('tab', 'image-library', $current_url);
551
552 // First page
553 if ($current === 1) {
554 $first_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
555 } else {
556 $first_link = sprintf(
557 '<a class="first-page button" href="%s"><span aria-hidden="true">%s</span></a>',
558 esc_url(add_query_arg(self::PAGED_PARAM, 1, $current_url)),
559 '&laquo;'
560 );
561 }
562
563 // Prev page
564 if ($current === 1) {
565 $prev_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
566 } else {
567 $prev_link = sprintf(
568 '<a class="prev-page button" href="%s"><span aria-hidden="true">%s</span></a>',
569 esc_url(add_query_arg(self::PAGED_PARAM, max(1, $current - 1), $current_url)),
570 '&lsaquo;'
571 );
572 }
573
574 // Page input
575 $html_current_page = sprintf(
576 '<input class="current-page" id="current-page-selector" type="text" name="%s" value="%s" size="%d" aria-describedby="table-paging" />',
577 self::PAGED_PARAM,
578 $current,
579 strlen($total_pages)
580 );
581
582 $html_total_pages = sprintf('<span class="total-pages">%s</span>', number_format_i18n($total_pages));
583
584 // Next page
585 if ($current >= $total_pages) {
586 $next_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
587 } else {
588 $next_link = sprintf(
589 '<a class="next-page button" href="%s"><span aria-hidden="true">%s</span></a>',
590 esc_url(add_query_arg(self::PAGED_PARAM, min($total_pages, $current + 1), $current_url)),
591 '&rsaquo;'
592 );
593 }
594
595 // Last page
596 if ($current >= $total_pages) {
597 $last_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
598 } else {
599 $last_link = sprintf(
600 '<a class="last-page button" href="%s"><span aria-hidden="true">%s</span></a>',
601 esc_url(add_query_arg(self::PAGED_PARAM, $total_pages, $current_url)),
602 '&raquo;'
603 );
604 }
605
606 $pagination_links_class = 'pagination-links';
607 $output .= "\n<span class='$pagination_links_class'>" .
608 $first_link . $prev_link .
609 '<span class="paging-input">' .
610 sprintf(
611 /* translators: 1: current page number, 2: total number of pages. */
612 _x('%1$s of %2$s', 'paging', 'metasync'),
613 $html_current_page,
614 $html_total_pages
615 ) .
616 '</span>' .
617 $next_link . $last_link .
618 '</span>';
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()
621 echo "<div class='tablenav-pages'>$output</div>";
622 }
623
624 /**
625 * Override print_column_headers for custom sort params.
626 */
627 public function print_column_headers($with_id = true): void {
628 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
629
630 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
631 $current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
632
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
635
636 foreach ($columns as $column_key => $column_display_name) {
637 $class = ['manage-column', "column-$column_key"];
638
639 if (in_array($column_key, $hidden, true)) {
640 $class[] = 'hidden';
641 }
642
643 if ($column_key === 'cb') {
644 $class[] = 'check-column';
645 }
646
647 $attr = '';
648
649 if (isset($sortable[$column_key])) {
650 list($orderby, $desc_first) = $sortable[$column_key];
651
652 if ($current_orderby === $orderby) {
653 $order = ($current_order === 'asc') ? 'desc' : 'asc';
654 $class[] = 'sorted';
655 $class[] = $current_order;
656 } else {
657 $order = $desc_first ? 'desc' : 'asc';
658 $class[] = 'sortable';
659 $class[] = $desc_first ? 'asc' : 'desc';
660 }
661
662 $sort_url = add_query_arg([
663 self::ORDERBY_PARAM => $orderby,
664 self::ORDER_PARAM => $order,
665 ], $current_url);
666
667 $column_display_name = sprintf(
668 '<a href="%s"><span>%s</span><span class="sorting-indicators"><span class="sorting-indicator asc" aria-hidden="true"></span><span class="sorting-indicator desc" aria-hidden="true"></span></span></a>',
669 esc_url($sort_url),
670 esc_html($column_display_name)
671 );
672 } elseif ($column_key !== 'cb') {
673 $column_display_name = esc_html($column_display_name);
674 }
675
676 $tag = ($column_key === 'cb') ? 'td' : 'th';
677 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
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()
680 echo '<' . esc_attr($tag) . ' ' . $id . " class='" . esc_attr(implode(' ', $class)) . "' " . esc_attr($attr) . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
681 }
682 }
683
684 /**
685 * Batch-resolve all post relationships for the current page of attachments.
686 *
687 * Checks three sources:
688 * 1. post_parent (direct attachment parent)
689 * 2. _thumbnail_id post meta (featured image)
690 * 3. Post content references (wp-image-{ID} class used by WordPress editor)
691 */
692 private function resolve_attachment_posts(): void {
693 $this->attachment_posts = [];
694
695 if (empty($this->items)) {
696 return;
697 }
698
699 global $wpdb;
700
701 $ids = array_map('intval', wp_list_pluck($this->items, 'ID'));
702
703 // 1. post_parent relationships
704 foreach ($this->items as $item) {
705 if (!empty($item->post_parent)) {
706 $parent = get_post($item->post_parent);
707 if ($parent && $parent->post_type !== 'attachment') {
708 $this->attachment_posts[$item->ID][] = (int) $item->post_parent;
709 }
710 }
711 }
712
713 // 2. Featured image relationships (_thumbnail_id meta)
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 )
725 );
726
727 foreach ($featured_rows as $row) {
728 $att_id = (int) $row->attachment_id;
729 $post_id = (int) $row->post_id;
730 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
731 $this->attachment_posts[$att_id][] = $post_id;
732 }
733 }
734
735 // 3. Content references: wp-image-{ID} class AND image filename in URLs
736 // Build a lookup of search tokens per attachment ID.
737 $search_map = []; // attachment_id => ['wp-image-123', 'filename.jpg']
738 $like_clauses = [];
739
740 foreach ($this->items as $item) {
741 $id = (int) $item->ID;
742 $tokens = [];
743
744 // wp-image-{ID} class (WordPress editor / Gutenberg blocks)
745 $tokens[] = 'wp-image-' . $id;
746
747 // Image filename from the attachment file path
748 $file = get_attached_file($id);
749 if ($file) {
750 $basename = basename($file);
751 if ($basename) {
752 $tokens[] = $basename;
753 }
754 }
755
756 $search_map[$id] = $tokens;
757
758 foreach ($tokens as $token) {
759 $escaped = $wpdb->esc_like($token);
760 $like_clauses[] = $wpdb->prepare('p.post_content LIKE %s', '%' . $escaped . '%');
761 }
762 }
763
764 if (!empty($like_clauses)) {
765 // Deduplicate clauses to avoid redundant OR conditions
766 $like_clauses = array_unique($like_clauses);
767 $like_sql = implode(' OR ', $like_clauses);
768
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
774 "SELECT p.ID AS post_id, p.post_content
775 FROM {$wpdb->posts} p
776 WHERE ({$like_sql})
777 AND p.post_type NOT IN ('attachment','revision')
778 AND p.post_status IN ('publish','draft','private','pending','future')"
779 );
780 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
781
782 foreach ($content_rows as $row) {
783 $post_id = (int) $row->post_id;
784 foreach ($search_map as $att_id => $tokens) {
785 foreach ($tokens as $token) {
786 if (strpos($row->post_content, $token) !== false) {
787 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
788 $this->attachment_posts[$att_id][] = $post_id;
789 }
790 break; // One match is enough for this attachment+post pair
791 }
792 }
793 }
794 }
795 }
796 }
797
798 /**
799 * Get sanitized filter values from request.
800 */
801 private function get_filters(): array {
802 return [
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
807 ];
808 }
809
810 /**
811 * Get image optimization statistics.
812 */
813 /**
814 * Mime types that are already in an optimized format and do not need conversion.
815 */
816 private const OPTIMIZED_MIME_TYPES = ['image/webp', 'image/avif'];
817
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 {
878 $total = (new WP_Query([
879 'post_type' => 'attachment',
880 'post_status' => 'inherit',
881 'post_mime_type' => self::IMAGE_MIME_TYPES,
882 'posts_per_page' => 1,
883 'fields' => 'ids',
884 ]))->found_posts;
885
886 $converted = (new WP_Query([
887 'post_type' => 'attachment',
888 'post_status' => 'inherit',
889 'post_mime_type' => self::IMAGE_MIME_TYPES,
890 'posts_per_page' => 1,
891 'fields' => 'ids',
892 'meta_query' => [
893 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
894 ],
895 ]))->found_posts;
896
897 // WebP/AVIF images without the conversion meta are natively optimized
898 // and should not appear as "unoptimized" since they cannot be converted.
899 $natively_optimized = (new WP_Query([
900 'post_type' => 'attachment',
901 'post_status' => 'inherit',
902 'post_mime_type' => self::OPTIMIZED_MIME_TYPES,
903 'posts_per_page' => 1,
904 'fields' => 'ids',
905 'meta_query' => [
906 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
907 ],
908 ]))->found_posts;
909
910 $optimized = $converted + $natively_optimized;
911
912 return [
913 'total' => $total,
914 'optimized' => $optimized,
915 'unoptimized' => $total - $optimized,
916 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
917 ];
918 }
919
920 /**
921 * Truncate a URL for display.
922 */
923 private function truncate_url(string $url, int $max = 50): string {
924 if (strlen($url) <= $max) {
925 return $url;
926 }
927 return substr($url, 0, $max - 3) . '...';
928 }
929
930 /**
931 * Truncate text for display.
932 */
933 private function truncate_text(string $text, int $max = 40): string {
934 if (strlen($text) <= $max) {
935 return $text;
936 }
937 return substr($text, 0, $max - 3) . '...';
938 }
939 }
940