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

860 lines 31.0 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 $file = get_attached_file($attachment_id);
220 $size = ($file && file_exists($file)) ? size_format((int) filesize($file), 1) : '';
221 $size_html = $size ? sprintf(
222 '<div class="metasync-size-info"><span class="metasync-size-detail">%s</span></div>',
223 esc_html($size)
224 ) : '';
225
226 return sprintf(
227 '<span class="metasync-status-badge metasync-status-unoptimized">%s</span>%s',
228 esc_html__('Unoptimized', 'metasync'),
229 $size_html
230 );
231 }
232
233 $badge = sprintf(
234 '<span class="metasync-status-badge metasync-status-optimized">%s (%s)</span>',
235 esc_html__('Optimized', 'metasync'),
236 esc_html(strtoupper($format))
237 );
238
239 $savings = self::get_size_savings($attachment_id);
240 if (!$savings) {
241 return $badge;
242 }
243
244 return $badge . sprintf(
245 '<div class="metasync-size-savings">'
246 . '<span class="metasync-savings-pct">&darr; %s%%</span>'
247 . '<span class="metasync-savings-detail">%s &rarr; %s</span>'
248 . '</div>',
249 esc_html($savings['percentage']),
250 esc_html($savings['original_human']),
251 esc_html($savings['converted_human'])
252 );
253 }
254
255 /**
256 * Calculate size savings for an optimized attachment.
257 */
258 private static function get_size_savings(int $attachment_id): ?array { // phpcs:ignore WordPress.NamingConventions
259 $file = get_attached_file($attachment_id);
260 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
261
262 if (!$file || !$format) {
263 return null;
264 }
265
266 // Original size: prefer stored meta, fall back to reading original file from disk
267 $original_size = (int) get_post_meta($attachment_id, '_metasync_original_filesize', true);
268 if (!$original_size && file_exists($file)) {
269 $original_size = (int) filesize($file);
270 // Backfill meta for future lookups
271 if ($original_size) {
272 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
273 }
274 }
275
276 if (!$original_size) {
277 return null;
278 }
279
280 $ext = $format === 'avif' ? '.avif' : '.webp';
281 $converted_path = preg_replace('/\.(jpe?g|png)$/i', $ext, $file);
282 $converted_size = ($converted_path && file_exists($converted_path)) ? (int) filesize($converted_path) : 0;
283
284 if (!$converted_size) {
285 return null;
286 }
287
288 return [
289 'percentage' => max(0, (int) round((($original_size - $converted_size) / $original_size) * 100)),
290 'original_human' => size_format($original_size, 1),
291 'converted_human' => size_format($converted_size, 1),
292 ];
293 }
294
295 /**
296 * Actions column: optimize or revert button.
297 */
298 protected function column_actions($item): string {
299 // Orphaned attachment (missing file): offer cleanup of the stale record.
300 if (self::is_file_missing($item->ID)) {
301 return sprintf(
302 '<button type="button" class="button button-small metasync-delete-orphan-btn" data-id="%d">
303 <span class="dashicons dashicons-trash" style="margin-top:3px;"></span> %s
304 </button>',
305 $item->ID,
306 esc_html__('Delete record', 'metasync')
307 );
308 }
309
310 $format = get_post_meta($item->ID, '_metasync_converted_format', true);
311
312 if ($format) {
313 if (Metasync_Image_Converter::can_revert($item->ID)) {
314 return sprintf(
315 '<button type="button" class="button button-small metasync-revert-btn" data-id="%d">
316 <span class="dashicons dashicons-undo" style="margin-top:3px;"></span> %s
317 </button>',
318 $item->ID,
319 esc_html__('Revert', 'metasync')
320 );
321 }
322
323 return sprintf(
324 '<button type="button" class="button button-small metasync-revert-btn" data-id="%d" disabled title="%s">
325 <span class="dashicons dashicons-undo" style="margin-top:3px;"></span> %s
326 </button>',
327 $item->ID,
328 esc_attr__('Original image unavailable — revert is disabled', 'metasync'),
329 esc_html__('Revert', 'metasync')
330 );
331 }
332
333 // Only show Optimize for source formats (JPEG/PNG), not for natively
334 // uploaded WebP/AVIF images that were never converted by us.
335 $mime = get_post_mime_type($item->ID);
336 if (!in_array($mime, self::OPTIMIZABLE_MIME_TYPES, true)) {
337 return '<span class="metasync-text-muted">' . esc_html__('N/A', 'metasync') . '</span>';
338 }
339
340 return sprintf(
341 '<button type="button" class="button button-small button-primary metasync-optimize-btn" data-id="%d">
342 <span class="dashicons dashicons-performance" style="margin-top:3px;"></span> %s
343 </button>',
344 $item->ID,
345 esc_html__('Optimize', 'metasync')
346 );
347 }
348
349 /**
350 * Default column handler.
351 */
352 protected function column_default($item, $column_name): string {
353 return '';
354 }
355
356 /**
357 * Bulk actions dropdown.
358 */
359 protected function get_bulk_actions(): array {
360 return [
361 'bulk_optimize' => __('Optimize Selected', 'metasync'),
362 'bulk_unoptimize' => __('Unoptimize Selected', 'metasync'),
363 ];
364 }
365
366 /**
367 * Extra controls: status filter dropdown.
368 */
369 protected function extra_tablenav($which): void {
370 if ($which !== 'top') {
371 return;
372 }
373
374 $current_status = isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '';
375 ?>
376 <div class="alignleft actions">
377 <select name="status_filter">
378 <option value=""><?php esc_html_e('All Images', 'metasync'); ?></option>
379 <option value="optimized" <?php selected($current_status, 'optimized'); ?>>
380 <?php esc_html_e('Optimized', 'metasync'); ?>
381 </option>
382 <option value="unoptimized" <?php selected($current_status, 'unoptimized'); ?>>
383 <?php esc_html_e('Unoptimized', 'metasync'); ?>
384 </option>
385 </select>
386 <?php submit_button(__('Filter', 'metasync'), '', 'filter_action', false); ?>
387 </div>
388 <?php
389 }
390
391 /**
392 * Prepare items for display.
393 */
394 public function prepare_items(): void {
395 $this->_column_headers = [
396 $this->get_columns(),
397 [],
398 $this->get_sortable_columns(),
399 ];
400
401 $per_page = Metasync_Per_Page_Helper::resolve('media_library', self::DEFAULT_PER_PAGE);
402 $this->resolved_per_page = $per_page;
403
404 $current_page = $this->get_pagenum();
405 $filters = $this->get_filters();
406
407 $query_args = [
408 'post_type' => 'attachment',
409 'post_status' => 'inherit',
410 'post_mime_type' => self::IMAGE_MIME_TYPES,
411 'posts_per_page' => $per_page,
412 'paged' => $current_page,
413 'orderby' => $filters['orderby'],
414 'order' => $filters['order'],
415 ];
416
417 // Use custom WHERE/JOIN filters instead of WP_Query's 's' parameter,
418 // which doesn't search _wp_attached_file meta and has quirks with
419 // post_status 'inherit' (attachments).
420 $join_cb = null;
421 $where_cb = null;
422 $distinct_cb = null;
423
424 if (!empty($filters['search'])) {
425 $search_term = $filters['search'];
426
427 $join_cb = function ($join) {
428 global $wpdb;
429 $join .= " LEFT JOIN {$wpdb->postmeta} AS mtsearch ON ({$wpdb->posts}.ID = mtsearch.post_id AND mtsearch.meta_key = '_wp_attached_file')";
430 return $join;
431 };
432
433 $where_cb = function ($where) use ($search_term) {
434 global $wpdb;
435 $like = '%' . $wpdb->esc_like($search_term) . '%';
436 $where .= $wpdb->prepare(
437 " AND ({$wpdb->posts}.post_title LIKE %s OR mtsearch.meta_value LIKE %s)",
438 $like,
439 $like
440 );
441 return $where;
442 };
443
444 $distinct_cb = function () {
445 return 'DISTINCT';
446 };
447
448 add_filter('posts_join', $join_cb);
449 add_filter('posts_where', $where_cb);
450 add_filter('posts_distinct', $distinct_cb);
451 }
452
453 if ($filters['status'] === 'optimized') {
454 $query_args['meta_query'] = [
455 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
456 ];
457 } elseif ($filters['status'] === 'unoptimized') {
458 $query_args['meta_query'] = [
459 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
460 ];
461 }
462
463 try {
464 $query = new WP_Query($query_args);
465 } finally {
466 if ($join_cb) {
467 remove_filter('posts_join', $join_cb);
468 }
469 if ($where_cb) {
470 remove_filter('posts_where', $where_cb);
471 }
472 if ($distinct_cb) {
473 remove_filter('posts_distinct', $distinct_cb);
474 }
475 }
476
477 $this->items = $query->posts;
478 $this->resolve_attachment_posts();
479
480 $this->set_pagination_args([
481 'total_items' => $query->found_posts,
482 'per_page' => $per_page,
483 'total_pages' => $query->max_num_pages,
484 ]);
485 }
486
487 /**
488 * Override get_pagenum to use custom paged param.
489 */
490 public function get_pagenum(): int {
491 $pagenum = isset($_REQUEST[self::PAGED_PARAM]) ? absint($_REQUEST[self::PAGED_PARAM]) : 0;
492
493 if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
494 $pagenum = $this->_pagination_args['total_pages'];
495 }
496
497 return max(1, $pagenum);
498 }
499
500 /**
501 * Override pagination to use custom paged param and preserve tab.
502 */
503 protected function pagination($which): void {
504 if (empty($this->_pagination_args)) {
505 return;
506 }
507
508 $total_items = (int) $this->_pagination_args['total_items'];
509 $total_pages = (int) $this->_pagination_args['total_pages'];
510 $current = $this->get_pagenum();
511
512 $output = '<span class="displaying-num">' . sprintf(
513 _n('%s item', '%s items', $total_items, 'metasync'),
514 number_format_i18n($total_items)
515 ) . '</span>';
516
517 // Inline "rows per page" selector (10/20/50/100), persisted per user.
518 // Rendered unconditionally so the control is always available, even
519 // when there is only a single page of results. $which keeps the top and
520 // bottom selectors' ids distinct.
521 $output .= Metasync_Per_Page_Helper::render_selector('media_library', $this->resolved_per_page, $which);
522
523 if ($total_pages <= 1) {
524 echo "<div class='tablenav-pages one-page'>$output</div>";
525 return;
526 }
527
528 $removable_args = [
529 'paged', 'paged_redir', 'paged_404',
530 '_wpnonce', '_wp_http_referer',
531 'action', 'action2',
532 ];
533
534 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
535 $current_url = remove_query_arg($removable_args, $current_url);
536 $current_url = add_query_arg('tab', 'image-library', $current_url);
537
538 // First page
539 if ($current === 1) {
540 $first_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&laquo;</span>';
541 } else {
542 $first_link = sprintf(
543 '<a class="first-page button" href="%s"><span aria-hidden="true">%s</span></a>',
544 esc_url(add_query_arg(self::PAGED_PARAM, 1, $current_url)),
545 '&laquo;'
546 );
547 }
548
549 // Prev page
550 if ($current === 1) {
551 $prev_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&lsaquo;</span>';
552 } else {
553 $prev_link = sprintf(
554 '<a class="prev-page button" href="%s"><span aria-hidden="true">%s</span></a>',
555 esc_url(add_query_arg(self::PAGED_PARAM, max(1, $current - 1), $current_url)),
556 '&lsaquo;'
557 );
558 }
559
560 // Page input
561 $html_current_page = sprintf(
562 '<input class="current-page" id="current-page-selector" type="text" name="%s" value="%s" size="%d" aria-describedby="table-paging" />',
563 self::PAGED_PARAM,
564 $current,
565 strlen($total_pages)
566 );
567
568 $html_total_pages = sprintf('<span class="total-pages">%s</span>', number_format_i18n($total_pages));
569
570 // Next page
571 if ($current >= $total_pages) {
572 $next_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&rsaquo;</span>';
573 } else {
574 $next_link = sprintf(
575 '<a class="next-page button" href="%s"><span aria-hidden="true">%s</span></a>',
576 esc_url(add_query_arg(self::PAGED_PARAM, min($total_pages, $current + 1), $current_url)),
577 '&rsaquo;'
578 );
579 }
580
581 // Last page
582 if ($current >= $total_pages) {
583 $last_link = '<span class="tablenav-pages-navspan button disabled" aria-hidden="true">&raquo;</span>';
584 } else {
585 $last_link = sprintf(
586 '<a class="last-page button" href="%s"><span aria-hidden="true">%s</span></a>',
587 esc_url(add_query_arg(self::PAGED_PARAM, $total_pages, $current_url)),
588 '&raquo;'
589 );
590 }
591
592 $pagination_links_class = 'pagination-links';
593 $output .= "\n<span class='$pagination_links_class'>" .
594 $first_link . $prev_link .
595 '<span class="paging-input">' .
596 sprintf(
597 _x('%1$s of %2$s', 'paging', 'metasync'),
598 $html_current_page,
599 $html_total_pages
600 ) .
601 '</span>' .
602 $next_link . $last_link .
603 '</span>';
604
605 echo "<div class='tablenav-pages'>$output</div>";
606 }
607
608 /**
609 * Override print_column_headers for custom sort params.
610 */
611 public function print_column_headers($with_id = true): void {
612 list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
613
614 $current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
615 $current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
616
617 $current_orderby = isset($_GET[self::ORDERBY_PARAM]) ? sanitize_text_field($_GET[self::ORDERBY_PARAM]) : '';
618 $current_order = isset($_GET[self::ORDER_PARAM]) ? sanitize_text_field($_GET[self::ORDER_PARAM]) : 'asc';
619
620 foreach ($columns as $column_key => $column_display_name) {
621 $class = ['manage-column', "column-$column_key"];
622
623 if (in_array($column_key, $hidden, true)) {
624 $class[] = 'hidden';
625 }
626
627 if ($column_key === 'cb') {
628 $class[] = 'check-column';
629 }
630
631 $attr = '';
632
633 if (isset($sortable[$column_key])) {
634 list($orderby, $desc_first) = $sortable[$column_key];
635
636 if ($current_orderby === $orderby) {
637 $order = ($current_order === 'asc') ? 'desc' : 'asc';
638 $class[] = 'sorted';
639 $class[] = $current_order;
640 } else {
641 $order = $desc_first ? 'desc' : 'asc';
642 $class[] = 'sortable';
643 $class[] = $desc_first ? 'asc' : 'desc';
644 }
645
646 $sort_url = add_query_arg([
647 self::ORDERBY_PARAM => $orderby,
648 self::ORDER_PARAM => $order,
649 ], $current_url);
650
651 $column_display_name = sprintf(
652 '<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>',
653 esc_url($sort_url),
654 esc_html($column_display_name)
655 );
656 } elseif ($column_key !== 'cb') {
657 $column_display_name = esc_html($column_display_name);
658 }
659
660 $tag = ($column_key === 'cb') ? 'td' : 'th';
661 $id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
662
663 echo '<' . esc_attr($tag) . ' ' . $id . " class='" . esc_attr(implode(' ', $class)) . "' " . esc_attr($attr) . '>' . $column_display_name . '</' . esc_attr($tag) . '>';
664 }
665 }
666
667 /**
668 * Batch-resolve all post relationships for the current page of attachments.
669 *
670 * Checks three sources:
671 * 1. post_parent (direct attachment parent)
672 * 2. _thumbnail_id post meta (featured image)
673 * 3. Post content references (wp-image-{ID} class used by WordPress editor)
674 */
675 private function resolve_attachment_posts(): void {
676 $this->attachment_posts = [];
677
678 if (empty($this->items)) {
679 return;
680 }
681
682 global $wpdb;
683
684 $ids = wp_list_pluck($this->items, 'ID');
685 $id_set = implode(',', array_map('intval', $ids));
686 $valid_statuses = "'publish','draft','private','pending','future'";
687
688 // 1. post_parent relationships
689 foreach ($this->items as $item) {
690 if (!empty($item->post_parent)) {
691 $parent = get_post($item->post_parent);
692 if ($parent && $parent->post_type !== 'attachment') {
693 $this->attachment_posts[$item->ID][] = (int) $item->post_parent;
694 }
695 }
696 }
697
698 // 2. Featured image relationships (_thumbnail_id meta)
699 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
700 $featured_rows = $wpdb->get_results(
701 "SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
702 FROM {$wpdb->postmeta} pm
703 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
704 WHERE pm.meta_key = '_thumbnail_id'
705 AND pm.meta_value IN ({$id_set})
706 AND p.post_status IN ({$valid_statuses})
707 AND p.post_type NOT IN ('attachment','revision')"
708 );
709
710 foreach ($featured_rows as $row) {
711 $att_id = (int) $row->attachment_id;
712 $post_id = (int) $row->post_id;
713 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
714 $this->attachment_posts[$att_id][] = $post_id;
715 }
716 }
717
718 // 3. Content references: wp-image-{ID} class AND image filename in URLs
719 // Build a lookup of search tokens per attachment ID.
720 $search_map = []; // attachment_id => ['wp-image-123', 'filename.jpg']
721 $like_clauses = [];
722
723 foreach ($this->items as $item) {
724 $id = (int) $item->ID;
725 $tokens = [];
726
727 // wp-image-{ID} class (WordPress editor / Gutenberg blocks)
728 $tokens[] = 'wp-image-' . $id;
729
730 // Image filename from the attachment file path
731 $file = get_attached_file($id);
732 if ($file) {
733 $basename = basename($file);
734 if ($basename) {
735 $tokens[] = $basename;
736 }
737 }
738
739 $search_map[$id] = $tokens;
740
741 foreach ($tokens as $token) {
742 $escaped = $wpdb->esc_like($token);
743 $like_clauses[] = $wpdb->prepare('p.post_content LIKE %s', '%' . $escaped . '%');
744 }
745 }
746
747 if (!empty($like_clauses)) {
748 // Deduplicate clauses to avoid redundant OR conditions
749 $like_clauses = array_unique($like_clauses);
750 $like_sql = implode(' OR ', $like_clauses);
751
752 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
753 $content_rows = $wpdb->get_results(
754 "SELECT p.ID AS post_id, p.post_content
755 FROM {$wpdb->posts} p
756 WHERE ({$like_sql})
757 AND p.post_type NOT IN ('attachment','revision')
758 AND p.post_status IN ({$valid_statuses})"
759 );
760
761 foreach ($content_rows as $row) {
762 $post_id = (int) $row->post_id;
763 foreach ($search_map as $att_id => $tokens) {
764 foreach ($tokens as $token) {
765 if (strpos($row->post_content, $token) !== false) {
766 if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
767 $this->attachment_posts[$att_id][] = $post_id;
768 }
769 break; // One match is enough for this attachment+post pair
770 }
771 }
772 }
773 }
774 }
775 }
776
777 /**
778 * Get sanitized filter values from request.
779 */
780 private function get_filters(): array {
781 return [
782 'search' => isset($_REQUEST['s']) ? sanitize_text_field($_REQUEST['s']) : '',
783 'status' => isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '',
784 'orderby' => isset($_REQUEST[self::ORDERBY_PARAM]) ? sanitize_sql_orderby($_REQUEST[self::ORDERBY_PARAM]) ?: 'date' : 'date',
785 'order' => isset($_REQUEST[self::ORDER_PARAM]) && strtolower($_REQUEST[self::ORDER_PARAM]) === 'asc' ? 'ASC' : 'DESC',
786 ];
787 }
788
789 /**
790 * Get image optimization statistics.
791 */
792 /**
793 * Mime types that are already in an optimized format and do not need conversion.
794 */
795 private const OPTIMIZED_MIME_TYPES = ['image/webp', 'image/avif'];
796
797 public static function get_stats(): array {
798 $total = (new WP_Query([
799 'post_type' => 'attachment',
800 'post_status' => 'inherit',
801 'post_mime_type' => self::IMAGE_MIME_TYPES,
802 'posts_per_page' => -1,
803 'fields' => 'ids',
804 ]))->found_posts;
805
806 $converted = (new WP_Query([
807 'post_type' => 'attachment',
808 'post_status' => 'inherit',
809 'post_mime_type' => self::IMAGE_MIME_TYPES,
810 'posts_per_page' => -1,
811 'fields' => 'ids',
812 'meta_query' => [
813 ['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
814 ],
815 ]))->found_posts;
816
817 // WebP/AVIF images without the conversion meta are natively optimized
818 // and should not appear as "unoptimized" since they cannot be converted.
819 $natively_optimized = (new WP_Query([
820 'post_type' => 'attachment',
821 'post_status' => 'inherit',
822 'post_mime_type' => self::OPTIMIZED_MIME_TYPES,
823 'posts_per_page' => -1,
824 'fields' => 'ids',
825 'meta_query' => [
826 ['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
827 ],
828 ]))->found_posts;
829
830 $optimized = $converted + $natively_optimized;
831
832 return [
833 'total' => $total,
834 'optimized' => $optimized,
835 'unoptimized' => $total - $optimized,
836 'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
837 ];
838 }
839
840 /**
841 * Truncate a URL for display.
842 */
843 private function truncate_url(string $url, int $max = 50): string {
844 if (strlen($url) <= $max) {
845 return $url;
846 }
847 return substr($url, 0, $max - 3) . '...';
848 }
849
850 /**
851 * Truncate text for display.
852 */
853 private function truncate_text(string $text, int $max = 40): string {
854 if (strlen($text) <= $max) {
855 return $text;
856 }
857 return substr($text, 0, $max - 3) . '...';
858 }
859 }
860