PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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.1, at media-optimization/class-media-library-list-table.php

932 lines 35.4 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 echo "<div class='tablenav-pages one-page'>$output</div>";
538 return;
539 }
540
541 $removable_args = [
542 'paged', 'paged_redir', 'paged_404',
543 '_wpnonce', '_wp_http_referer',
544 'action', 'action2',
545 ];
546
547 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
548 $current_url = remove_query_arg($removable_args, $current_url);
549 $current_url = add_query_arg('tab', 'image-library', $current_url);
550
551 // First page
552 if ($current === 1) {
553 $first_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
554 } else {
555 $first_link = sprintf(
556 '<a class="first-page button" href="%s"><span aria-hidden="true">%s</span></a>',
557 esc_url(add_query_arg(self::PAGED_PARAM, 1, $current_url)),
558 '&laquo;'
559 );
560 }
561
562 // Prev page
563 if ($current === 1) {
564 $prev_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
565 } else {
566 $prev_link = sprintf(
567 '<a class="prev-page button" href="%s"><span aria-hidden="true">%s</span></a>',
568 esc_url(add_query_arg(self::PAGED_PARAM, max(1, $current - 1), $current_url)),
569 '&lsaquo;'
570 );
571 }
572
573 // Page input
574 $html_current_page = sprintf(
575 '<input class="current-page" id="current-page-selector" type="text" name="%s" value="%s" size="%d" aria-describedby="table-paging" />',
576 self::PAGED_PARAM,
577 $current,
578 strlen($total_pages)
579 );
580
581 $html_total_pages = sprintf('<span class="total-pages">%s</span>', number_format_i18n($total_pages));
582
583 // Next page
584 if ($current >= $total_pages) {
585 $next_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
586 } else {
587 $next_link = sprintf(
588 '<a class="next-page button" href="%s"><span aria-hidden="true">%s</span></a>',
589 esc_url(add_query_arg(self::PAGED_PARAM, min($total_pages, $current + 1), $current_url)),
590 '&rsaquo;'
591 );
592 }
593
594 // Last page
595 if ($current >= $total_pages) {
596 $last_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
597 } else {
598 $last_link = sprintf(
599 '<a class="last-page button" href="%s"><span aria-hidden="true">%s</span></a>',
600 esc_url(add_query_arg(self::PAGED_PARAM, $total_pages, $current_url)),
601 '&raquo;'
602 );
603 }
604
605 $pagination_links_class = 'pagination-links';
606 $output .= "\n<span class='$pagination_links_class'>" .
607 $first_link . $prev_link .
608 '<span class="paging-input">' .
609 sprintf(
610 /* translators: 1: current page number, 2: total number of pages. */
611 _x('%1$s of %2$s', 'paging', 'metasync'),
612 $html_current_page,
613 $html_total_pages
614 ) .
615 '</span>' .
616 $next_link . $last_link .
617 '</span>';
618
619 echo "<div class='tablenav-pages'>$output</div>";
620 }
621
622 /**
623 * Override print_column_headers for custom sort params.
624 */
625 public function print_column_headers($with_id = true): void {
626 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
627
628 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
629 $current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
630
631 $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
632 $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
633
634 foreach ($columns as $column_key => $column_display_name) {
635 $class = ['manage-column', "column-$column_key"];
636
637 if (in_array($column_key, $hidden, true)) {
638 $class[] = 'hidden';
639 }
640
641 if ($column_key === 'cb') {
642 $class[] = 'check-column';
643 }
644
645 $attr = '';
646
647 if (isset($sortable[$column_key])) {
648 list($orderby, $desc_first) = $sortable[$column_key];
649
650 if ($current_orderby === $orderby) {
651 $order = ($current_order === 'asc') ? 'desc' : 'asc';
652 $class[] = 'sorted';
653 $class[] = $current_order;
654 } else {
655 $order = $desc_first ? 'desc' : 'asc';
656 $class[] = 'sortable';
657 $class[] = $desc_first ? 'asc' : 'desc';
658 }
659
660 $sort_url = add_query_arg([
661 self::ORDERBY_PARAM => $orderby,
662 self::ORDER_PARAM => $order,
663 ], $current_url);
664
665 $column_display_name = sprintf(
666 '<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>',
667 esc_url($sort_url),
668 esc_html($column_display_name)
669 );
670 } elseif ($column_key !== 'cb') {
671 $column_display_name = esc_html($column_display_name);
672 }
673
674 $tag = ($column_key === 'cb') ? 'td' : 'th';
675 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
676
677 echo '<' . esc_attr($tag) . ' ' . $id . " class='" . esc_attr(implode(' ', $class)) . "' " . esc_attr($attr) . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
678 }
679 }
680
681 /**
682 * Batch-resolve all post relationships for the current page of attachments.
683 *
684 * Checks three sources:
685 * 1. post_parent (direct attachment parent)
686 * 2. _thumbnail_id post meta (featured image)
687 * 3. Post content references (wp-image-{ID} class used by WordPress editor)
688 */
689 private function resolve_attachment_posts(): void {
690 $this->attachment_posts = [];
691
692 if (empty($this->items)) {
693 return;
694 }
695
696 global $wpdb;
697
698 $ids = array_map('intval', wp_list_pluck($this->items, 'ID'));
699
700 // 1. post_parent relationships
701 foreach ($this->items as $item) {
702 if (!empty($item->post_parent)) {
703 $parent = get_post($item->post_parent);
704 if ($parent && $parent->post_type !== 'attachment') {
705 $this->attachment_posts[$item->ID][] = (int) $item->post_parent;
706 }
707 }
708 }
709
710 // 2. Featured image relationships (_thumbnail_id meta)
711 $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
712 $wpdb->prepare(
713 "SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
714 FROM {$wpdb->postmeta} pm
715 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
716 WHERE pm.meta_key = '_thumbnail_id'
717 AND pm.meta_value IN (" . implode(',', array_fill(0, count($ids), '%d')) . ")
718 AND p.post_status IN ('publish','draft','private','pending','future')
719 AND p.post_type NOT IN ('attachment','revision')",
720 $ids
721 )
722 );
723
724 foreach ($featured_rows as $row) {
725 $att_id = (int) $row->attachment_id;
726 $post_id = (int) $row->post_id;
727 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
728 $this->attachment_posts[$att_id][] = $post_id;
729 }
730 }
731
732 // 3. Content references: wp-image-{ID} class AND image filename in URLs
733 // Build a lookup of search tokens per attachment ID.
734 $search_map = []; // attachment_id => ['wp-image-123', 'filename.jpg']
735 $like_clauses = [];
736
737 foreach ($this->items as $item) {
738 $id = (int) $item->ID;
739 $tokens = [];
740
741 // wp-image-{ID} class (WordPress editor / Gutenberg blocks)
742 $tokens[] = 'wp-image-' . $id;
743
744 // Image filename from the attachment file path
745 $file = get_attached_file($id);
746 if ($file) {
747 $basename = basename($file);
748 if ($basename) {
749 $tokens[] = $basename;
750 }
751 }
752
753 $search_map[$id] = $tokens;
754
755 foreach ($tokens as $token) {
756 $escaped = $wpdb->esc_like($token);
757 $like_clauses[] = $wpdb->prepare('p.post_content LIKE %s', '%' . $escaped . '%');
758 }
759 }
760
761 if (!empty($like_clauses)) {
762 // Deduplicate clauses to avoid redundant OR conditions
763 $like_clauses = array_unique($like_clauses);
764 $like_sql = implode(' OR ', $like_clauses);
765
766 // Each LIKE fragment is itself prepare()'d above; the status list
767 // is literal. Only the OR-joined fragment list is dynamic, and every
768 // fragment in it carries its own bound value.
769 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- $like_sql is an OR-join of prepare()'d fragments built above
770 $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
771 "SELECT p.ID AS post_id, p.post_content
772 FROM {$wpdb->posts} p
773 WHERE ({$like_sql})
774 AND p.post_type NOT IN ('attachment','revision')
775 AND p.post_status IN ('publish','draft','private','pending','future')"
776 );
777 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
778
779 foreach ($content_rows as $row) {
780 $post_id = (int) $row->post_id;
781 foreach ($search_map as $att_id => $tokens) {
782 foreach ($tokens as $token) {
783 if (strpos($row->post_content, $token) !== false) {
784 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
785 $this->attachment_posts[$att_id][] = $post_id;
786 }
787 break; // One match is enough for this attachment+post pair
788 }
789 }
790 }
791 }
792 }
793 }
794
795 /**
796 * Get sanitized filter values from request.
797 */
798 private function get_filters(): array {
799 return [
800 'search' => isset($_REQUEST['s']) ? sanitize_text_field(wp_unslash($_REQUEST['s'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list-table view state
801 '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
802 '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
803 '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
804 ];
805 }
806
807 /**
808 * Get image optimization statistics.
809 */
810 /**
811 * Mime types that are already in an optimized format and do not need conversion.
812 */
813 private const OPTIMIZED_MIME_TYPES = ['image/webp', 'image/avif'];
814
815 public static function get_stats(): array {
816 // While a batch is running the browser polls every 500ms and every
817 // tick is a fresh PHP process, so library-wide scans cannot amortize.
818 // Derive the live numbers from the batch's own baseline + progress
819 // counters instead — zero queries per tick.
820 if (Metasync_Media_Batch_Optimizer::is_running()) {
821 $derived = self::derive_stats_from_batch();
822 if ($derived !== null) {
823 return $derived;
824 }
825 }
826
827 // Outside a batch, cache the three scans: the numbers only move on
828 // conversion, revert, or attachment deletion, each of which
829 // invalidates the transient.
830 $cached = get_transient(Metasync_Media_Batch_Optimizer::STATS_TRANSIENT);
831 if (is_array($cached) && isset($cached['total'], $cached['optimized'])) {
832 return $cached;
833 }
834
835 $stats = self::query_stats();
836 set_transient(Metasync_Media_Batch_Optimizer::STATS_TRANSIENT, $stats, 15 * MINUTE_IN_SECONDS);
837
838 return $stats;
839 }
840
841 /**
842 * Live stats for a running batch: its start-of-run baseline plus the
843 * batch's own progress counters. Every queued image was unoptimized at
844 * queue-build time, so each non-failed conversion moves exactly one
845 * image across.
846 */
847 private static function derive_stats_from_batch(): ?array {
848 $baseline = get_option(Metasync_Media_Batch_Optimizer::BASELINE_STATS_OPTION, []);
849 if (!is_array($baseline) || !isset($baseline['total'], $baseline['optimized'])) {
850 return null;
851 }
852
853 $progress = Metasync_Media_Batch_Optimizer::get_progress();
854 $optimized = (int) $baseline['optimized'] + (int) $progress['processed'] - (int) $progress['failed'];
855 $total = (int) $baseline['total'];
856
857 return [
858 'total' => $total,
859 'optimized' => $optimized,
860 'unoptimized' => max(0, $total - $optimized),
861 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
862 ];
863 }
864
865 /**
866 * The uncached library-wide scans. Called at most once per 15 minutes
867 * outside a batch, never during one.
868 */
869 private static function query_stats(): array {
870 $total = (new WP_Query([
871 'post_type' => 'attachment',
872 'post_status' => 'inherit',
873 'post_mime_type' => self::IMAGE_MIME_TYPES,
874 'posts_per_page' => -1,
875 'fields' => 'ids',
876 ]))->found_posts;
877
878 $converted = (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 'meta_query' => [
885 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
886 ],
887 ]))->found_posts;
888
889 // WebP/AVIF images without the conversion meta are natively optimized
890 // and should not appear as "unoptimized" since they cannot be converted.
891 $natively_optimized = (new WP_Query([
892 'post_type' => 'attachment',
893 'post_status' => 'inherit',
894 'post_mime_type' => self::OPTIMIZED_MIME_TYPES,
895 'posts_per_page' => -1,
896 'fields' => 'ids',
897 'meta_query' => [
898 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
899 ],
900 ]))->found_posts;
901
902 $optimized = $converted + $natively_optimized;
903
904 return [
905 'total' => $total,
906 'optimized' => $optimized,
907 'unoptimized' => $total - $optimized,
908 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
909 ];
910 }
911
912 /**
913 * Truncate a URL for display.
914 */
915 private function truncate_url(string $url, int $max = 50): string {
916 if (strlen($url) <= $max) {
917 return $url;
918 }
919 return substr($url, 0, $max - 3) . '...';
920 }
921
922 /**
923 * Truncate text for display.
924 */
925 private function truncate_text(string $text, int $max = 40): string {
926 if (strlen($text) <= $max) {
927 return $text;
928 }
929 return substr($text, 0, $max - 3) . '...';
930 }
931 }
932