*/
private array $attachment_posts = [];
public function __construct() {
parent::__construct([
'singular' => 'image',
'plural' => 'images',
'ajax' => false,
]);
}
/**
* Get table columns.
*/
public function get_columns(): array {
return [
'cb' => '',
'image' => __('Image', 'metasync'),
'post' => __('Post', 'metasync'),
'status' => __('Status', 'metasync'),
'actions' => __('Actions', 'metasync'),
];
}
/**
* Get sortable columns.
*/
protected function get_sortable_columns(): array {
return [
'image' => ['title', false],
];
}
/**
* Checkbox column for bulk actions.
*/
protected function column_cb($item): string {
return sprintf(
'',
$item->ID
);
}
/**
* Whether the attachment's primary file is missing on disk.
*
* Orphaned attachments (DB record present, file deleted externally or via a
* failed sideload) otherwise render broken thumbnails and can never be
* optimized. Used to flag these rows distinctly across the table.
*/
public static function is_file_missing(int $attachment_id): bool {
$path = get_attached_file($attachment_id);
return !$path || !file_exists($path);
}
/**
* Image column: thumbnail + filename + URL with copy button.
*/
protected function column_image($item): string {
if (self::is_file_missing($item->ID)) {
$thumb = sprintf(
'',
esc_attr__('File missing on disk', 'metasync')
);
} else {
$thumb = wp_get_attachment_image($item->ID, [50, 50], true, ['style' => 'border-radius:4px;']);
}
$url = wp_get_attachment_url($item->ID);
$file = basename(get_attached_file($item->ID) ?: '');
return sprintf(
'
',
$thumb,
esc_html($file),
esc_url($url),
esc_attr($url),
esc_html($this->truncate_url($url)),
esc_attr($url),
esc_attr__('Copy URL', 'metasync')
);
}
/**
* Post column: linked posts or "Unattached".
*
* Shows all posts that use this attachment (via post_parent, featured image, or content embedding).
*/
protected function column_post($item): string {
$post_ids = $this->attachment_posts[$item->ID] ?? [];
if (empty($post_ids)) {
return '' . esc_html__('Unattached', 'metasync') . '';
}
$links = [];
foreach ($post_ids as $post_id) {
$post = get_post($post_id);
if (!$post) {
continue;
}
$permalink = get_permalink($post->ID);
$title = $post->post_title ?: __('(no title)', 'metasync');
$links[] = sprintf(
'',
esc_url($permalink),
esc_attr($title),
esc_html($this->truncate_text($title, 40)),
esc_attr($permalink),
esc_attr__('Copy post URL', 'metasync')
);
}
if (empty($links)) {
return '' . esc_html__('Unattached', 'metasync') . '';
}
return implode('', $links);
}
/**
* Status column: optimized badge with savings or unoptimized.
*/
protected function column_status($item): string {
return self::render_status_html($item->ID);
}
/**
* Render status HTML for a given attachment.
* Reusable by AJAX handlers (DRY).
*/
public static function render_status_html(int $attachment_id): string {
// Orphaned attachment: file no longer exists on disk.
if (self::is_file_missing($attachment_id)) {
return sprintf(
'%s',
esc_html__('Missing file', 'metasync')
);
}
$format = get_post_meta($attachment_id, '_metasync_converted_format', true);
if (!$format) {
$file = get_attached_file($attachment_id);
$size = ($file && file_exists($file)) ? size_format((int) filesize($file), 1) : '';
$size_html = $size ? sprintf(
'%s
',
esc_html($size)
) : '';
return sprintf(
'%s%s',
esc_html__('Unoptimized', 'metasync'),
$size_html
);
}
$badge = sprintf(
'%s (%s)',
esc_html__('Optimized', 'metasync'),
esc_html(strtoupper($format))
);
$savings = self::get_size_savings($attachment_id);
if (!$savings) {
return $badge;
}
return $badge . sprintf(
''
. '↓ %s%%'
. '%s → %s'
. '
',
esc_html($savings['percentage']),
esc_html($savings['original_human']),
esc_html($savings['converted_human'])
);
}
/**
* Calculate size savings for an optimized attachment.
*/
private static function get_size_savings(int $attachment_id): ?array { // phpcs:ignore WordPress.NamingConventions
$file = get_attached_file($attachment_id);
$format = get_post_meta($attachment_id, '_metasync_converted_format', true);
if (!$file || !$format) {
return null;
}
// Original size: prefer stored meta, fall back to reading original file from disk
$original_size = (int) get_post_meta($attachment_id, '_metasync_original_filesize', true);
if (!$original_size && file_exists($file)) {
$original_size = (int) filesize($file);
// Backfill meta for future lookups
if ($original_size) {
update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
}
}
if (!$original_size) {
return null;
}
$ext = $format === 'avif' ? '.avif' : '.webp';
$converted_path = preg_replace('/\.(jpe?g|png)$/i', $ext, $file);
$converted_size = ($converted_path && file_exists($converted_path)) ? (int) filesize($converted_path) : 0;
if (!$converted_size) {
return null;
}
return [
'percentage' => max(0, (int) round((($original_size - $converted_size) / $original_size) * 100)),
'original_human' => size_format($original_size, 1),
'converted_human' => size_format($converted_size, 1),
];
}
/**
* Actions column: optimize or revert button.
*/
protected function column_actions($item): string {
// Orphaned attachment (missing file): offer cleanup of the stale record.
if (self::is_file_missing($item->ID)) {
return sprintf(
'',
$item->ID,
esc_html__('Delete record', 'metasync')
);
}
$format = get_post_meta($item->ID, '_metasync_converted_format', true);
if ($format) {
if (Metasync_Image_Converter::can_revert($item->ID)) {
return sprintf(
'',
$item->ID,
esc_html__('Revert', 'metasync')
);
}
return sprintf(
'',
$item->ID,
esc_attr__('Original image unavailable — revert is disabled', 'metasync'),
esc_html__('Revert', 'metasync')
);
}
// Only show Optimize for source formats (JPEG/PNG), not for natively
// uploaded WebP/AVIF images that were never converted by us.
$mime = get_post_mime_type($item->ID);
if (!in_array($mime, self::OPTIMIZABLE_MIME_TYPES, true)) {
return '' . esc_html__('N/A', 'metasync') . '';
}
return sprintf(
'',
$item->ID,
esc_html__('Optimize', 'metasync')
);
}
/**
* Default column handler.
*/
protected function column_default($item, $column_name): string {
return '';
}
/**
* Bulk actions dropdown.
*/
protected function get_bulk_actions(): array {
return [
'bulk_optimize' => __('Optimize Selected', 'metasync'),
'bulk_unoptimize' => __('Unoptimize Selected', 'metasync'),
];
}
/**
* Extra controls: status filter dropdown.
*/
protected function extra_tablenav($which): void {
if ($which !== 'top') {
return;
}
$current_status = isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '';
?>
_column_headers = [
$this->get_columns(),
[],
$this->get_sortable_columns(),
];
$current_page = $this->get_pagenum();
$filters = $this->get_filters();
$query_args = [
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => self::IMAGE_MIME_TYPES,
'posts_per_page' => self::PER_PAGE,
'paged' => $current_page,
'orderby' => $filters['orderby'],
'order' => $filters['order'],
];
// Use custom WHERE/JOIN filters instead of WP_Query's 's' parameter,
// which doesn't search _wp_attached_file meta and has quirks with
// post_status 'inherit' (attachments).
$join_cb = null;
$where_cb = null;
$distinct_cb = null;
if (!empty($filters['search'])) {
$search_term = $filters['search'];
$join_cb = function ($join) {
global $wpdb;
$join .= " LEFT JOIN {$wpdb->postmeta} AS mtsearch ON ({$wpdb->posts}.ID = mtsearch.post_id AND mtsearch.meta_key = '_wp_attached_file')";
return $join;
};
$where_cb = function ($where) use ($search_term) {
global $wpdb;
$like = '%' . $wpdb->esc_like($search_term) . '%';
$where .= $wpdb->prepare(
" AND ({$wpdb->posts}.post_title LIKE %s OR mtsearch.meta_value LIKE %s)",
$like,
$like
);
return $where;
};
$distinct_cb = function () {
return 'DISTINCT';
};
add_filter('posts_join', $join_cb);
add_filter('posts_where', $where_cb);
add_filter('posts_distinct', $distinct_cb);
}
if ($filters['status'] === 'optimized') {
$query_args['meta_query'] = [
['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
];
} elseif ($filters['status'] === 'unoptimized') {
$query_args['meta_query'] = [
['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
];
}
try {
$query = new WP_Query($query_args);
} finally {
if ($join_cb) {
remove_filter('posts_join', $join_cb);
}
if ($where_cb) {
remove_filter('posts_where', $where_cb);
}
if ($distinct_cb) {
remove_filter('posts_distinct', $distinct_cb);
}
}
$this->items = $query->posts;
$this->resolve_attachment_posts();
$this->set_pagination_args([
'total_items' => $query->found_posts,
'per_page' => self::PER_PAGE,
'total_pages' => $query->max_num_pages,
]);
}
/**
* Override get_pagenum to use custom paged param.
*/
public function get_pagenum(): int {
$pagenum = isset($_REQUEST[self::PAGED_PARAM]) ? absint($_REQUEST[self::PAGED_PARAM]) : 0;
if (isset($this->_pagination_args['total_pages']) && $pagenum > $this->_pagination_args['total_pages']) {
$pagenum = $this->_pagination_args['total_pages'];
}
return max(1, $pagenum);
}
/**
* Override pagination to use custom paged param and preserve tab.
*/
protected function pagination($which): void {
if (empty($this->_pagination_args)) {
return;
}
$total_items = (int) $this->_pagination_args['total_items'];
$total_pages = (int) $this->_pagination_args['total_pages'];
$current = $this->get_pagenum();
if ($total_pages <= 1) {
return;
}
$output = '' . sprintf(
_n('%s item', '%s items', $total_items, 'metasync'),
number_format_i18n($total_items)
) . '';
$removable_args = [
'paged', 'paged_redir', 'paged_404',
'_wpnonce', '_wp_http_referer',
'action', 'action2',
];
$current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
$current_url = remove_query_arg($removable_args, $current_url);
$current_url = add_query_arg('tab', 'image-library', $current_url);
// First page
if ($current === 1) {
$first_link = '«';
} else {
$first_link = sprintf(
'%s',
esc_url(add_query_arg(self::PAGED_PARAM, 1, $current_url)),
'«'
);
}
// Prev page
if ($current === 1) {
$prev_link = '‹';
} else {
$prev_link = sprintf(
'%s',
esc_url(add_query_arg(self::PAGED_PARAM, max(1, $current - 1), $current_url)),
'‹'
);
}
// Page input
$html_current_page = sprintf(
'',
self::PAGED_PARAM,
$current,
strlen($total_pages)
);
$html_total_pages = sprintf('%s', number_format_i18n($total_pages));
// Next page
if ($current >= $total_pages) {
$next_link = '›';
} else {
$next_link = sprintf(
'%s',
esc_url(add_query_arg(self::PAGED_PARAM, min($total_pages, $current + 1), $current_url)),
'›'
);
}
// Last page
if ($current >= $total_pages) {
$last_link = '»';
} else {
$last_link = sprintf(
'%s',
esc_url(add_query_arg(self::PAGED_PARAM, $total_pages, $current_url)),
'»'
);
}
$pagination_links_class = 'pagination-links';
$output .= "\n';
echo "$output
";
}
/**
* Override print_column_headers for custom sort params.
*/
public function print_column_headers($with_id = true): void {
list($columns, $hidden, $sortable, $primary) = $this->get_column_info();
$current_url = set_url_scheme('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
$current_url = remove_query_arg(['paged', self::PAGED_PARAM], $current_url);
$current_orderby = isset($_GET[self::ORDERBY_PARAM]) ? sanitize_text_field($_GET[self::ORDERBY_PARAM]) : '';
$current_order = isset($_GET[self::ORDER_PARAM]) ? sanitize_text_field($_GET[self::ORDER_PARAM]) : 'asc';
foreach ($columns as $column_key => $column_display_name) {
$class = ['manage-column', "column-$column_key"];
if (in_array($column_key, $hidden, true)) {
$class[] = 'hidden';
}
if ($column_key === 'cb') {
$class[] = 'check-column';
}
$attr = '';
if (isset($sortable[$column_key])) {
list($orderby, $desc_first) = $sortable[$column_key];
if ($current_orderby === $orderby) {
$order = ($current_order === 'asc') ? 'desc' : 'asc';
$class[] = 'sorted';
$class[] = $current_order;
} else {
$order = $desc_first ? 'desc' : 'asc';
$class[] = 'sortable';
$class[] = $desc_first ? 'asc' : 'desc';
}
$sort_url = add_query_arg([
self::ORDERBY_PARAM => $orderby,
self::ORDER_PARAM => $order,
], $current_url);
$column_display_name = sprintf(
'%s',
esc_url($sort_url),
esc_html($column_display_name)
);
} elseif ($column_key !== 'cb') {
$column_display_name = esc_html($column_display_name);
}
$tag = ($column_key === 'cb') ? 'td' : 'th';
$id = $with_id ? "id='" . esc_attr($column_key) . "'" : '';
echo '<' . esc_attr($tag) . ' ' . $id . " class='" . esc_attr(implode(' ', $class)) . "' " . esc_attr($attr) . '>' . $column_display_name . '' . esc_attr($tag) . '>';
}
}
/**
* Batch-resolve all post relationships for the current page of attachments.
*
* Checks three sources:
* 1. post_parent (direct attachment parent)
* 2. _thumbnail_id post meta (featured image)
* 3. Post content references (wp-image-{ID} class used by WordPress editor)
*/
private function resolve_attachment_posts(): void {
$this->attachment_posts = [];
if (empty($this->items)) {
return;
}
global $wpdb;
$ids = wp_list_pluck($this->items, 'ID');
$id_set = implode(',', array_map('intval', $ids));
$valid_statuses = "'publish','draft','private','pending','future'";
// 1. post_parent relationships
foreach ($this->items as $item) {
if (!empty($item->post_parent)) {
$parent = get_post($item->post_parent);
if ($parent && $parent->post_type !== 'attachment') {
$this->attachment_posts[$item->ID][] = (int) $item->post_parent;
}
}
}
// 2. Featured image relationships (_thumbnail_id meta)
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$featured_rows = $wpdb->get_results(
"SELECT CAST(pm.meta_value AS UNSIGNED) AS attachment_id, pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_thumbnail_id'
AND pm.meta_value IN ({$id_set})
AND p.post_status IN ({$valid_statuses})
AND p.post_type NOT IN ('attachment','revision')"
);
foreach ($featured_rows as $row) {
$att_id = (int) $row->attachment_id;
$post_id = (int) $row->post_id;
if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
$this->attachment_posts[$att_id][] = $post_id;
}
}
// 3. Content references: wp-image-{ID} class AND image filename in URLs
// Build a lookup of search tokens per attachment ID.
$search_map = []; // attachment_id => ['wp-image-123', 'filename.jpg']
$like_clauses = [];
foreach ($this->items as $item) {
$id = (int) $item->ID;
$tokens = [];
// wp-image-{ID} class (WordPress editor / Gutenberg blocks)
$tokens[] = 'wp-image-' . $id;
// Image filename from the attachment file path
$file = get_attached_file($id);
if ($file) {
$basename = basename($file);
if ($basename) {
$tokens[] = $basename;
}
}
$search_map[$id] = $tokens;
foreach ($tokens as $token) {
$escaped = $wpdb->esc_like($token);
$like_clauses[] = $wpdb->prepare('p.post_content LIKE %s', '%' . $escaped . '%');
}
}
if (!empty($like_clauses)) {
// Deduplicate clauses to avoid redundant OR conditions
$like_clauses = array_unique($like_clauses);
$like_sql = implode(' OR ', $like_clauses);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$content_rows = $wpdb->get_results(
"SELECT p.ID AS post_id, p.post_content
FROM {$wpdb->posts} p
WHERE ({$like_sql})
AND p.post_type NOT IN ('attachment','revision')
AND p.post_status IN ({$valid_statuses})"
);
foreach ($content_rows as $row) {
$post_id = (int) $row->post_id;
foreach ($search_map as $att_id => $tokens) {
foreach ($tokens as $token) {
if (strpos($row->post_content, $token) !== false) {
if (!isset($this->attachment_posts[$att_id]) || !in_array($post_id, $this->attachment_posts[$att_id], true)) {
$this->attachment_posts[$att_id][] = $post_id;
}
break; // One match is enough for this attachment+post pair
}
}
}
}
}
}
/**
* Get sanitized filter values from request.
*/
private function get_filters(): array {
return [
'search' => isset($_REQUEST['s']) ? sanitize_text_field($_REQUEST['s']) : '',
'status' => isset($_REQUEST['status_filter']) ? sanitize_text_field($_REQUEST['status_filter']) : '',
'orderby' => isset($_REQUEST[self::ORDERBY_PARAM]) ? sanitize_sql_orderby($_REQUEST[self::ORDERBY_PARAM]) ?: 'date' : 'date',
'order' => isset($_REQUEST[self::ORDER_PARAM]) && strtolower($_REQUEST[self::ORDER_PARAM]) === 'asc' ? 'ASC' : 'DESC',
];
}
/**
* Get image optimization statistics.
*/
/**
* Mime types that are already in an optimized format and do not need conversion.
*/
private const OPTIMIZED_MIME_TYPES = ['image/webp', 'image/avif'];
public static function get_stats(): array {
$total = (new WP_Query([
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => self::IMAGE_MIME_TYPES,
'posts_per_page' => -1,
'fields' => 'ids',
]))->found_posts;
$converted = (new WP_Query([
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => self::IMAGE_MIME_TYPES,
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => [
['key' => '_metasync_converted_format', 'compare' => 'EXISTS'],
],
]))->found_posts;
// WebP/AVIF images without the conversion meta are natively optimized
// and should not appear as "unoptimized" since they cannot be converted.
$natively_optimized = (new WP_Query([
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => self::OPTIMIZED_MIME_TYPES,
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => [
['key' => '_metasync_converted_format', 'compare' => 'NOT EXISTS'],
],
]))->found_posts;
$optimized = $converted + $natively_optimized;
return [
'total' => $total,
'optimized' => $optimized,
'unoptimized' => $total - $optimized,
'percentage' => $total > 0 ? round(($optimized / $total) * 100) : 0,
];
}
/**
* Truncate a URL for display.
*/
private function truncate_url(string $url, int $max = 50): string {
if (strlen($url) <= $max) {
return $url;
}
return substr($url, 0, $max - 3) . '...';
}
/**
* Truncate text for display.
*/
private function truncate_text(string $text, int $max = 40): string {
if (strlen($text) <= $max) {
return $text;
}
return substr($text, 0, $max - 3) . '...';
}
}