PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
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 2.6.4 2.6.3 All 139 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.18, at media-optimization/class-media-library-list-table.php

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