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

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