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

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