My Blog
WordPress Media Library CSV Report with AJAX Export for Original Images
In this article, I explain how to build a custom WordPress admin tool that exports all Media Library images into a CSV file, focusing on the original image files rather than the generated image sizes / variants.
The goal is to produce a practical report that can be used for quotations, content audits, or organized image replacement across an existing website.
What the report includes
For each Media Library image, the export can include:
- The full image URL
- The filename
- The Alt Text
- The page / post / link where the image appears
- The date it was added to the system
Why an AJAX-based approach was needed
On smaller websites, a simple PHP export running in a single request may be enough. On larger websites, however, when there are many images, posts, and metadata records, that kind of request often leads to:
- Gateway Timeout
- 504 errors
- server limits from proxy / nginx / hosting layers
That is why the right approach is to run the export in batches via AJAX. This way:
- the whole process does not have to finish in a single request
- execution time stays lower per step
- we get visible progress feedback
- at the end we receive a ready download link for the CSV
Where the tool appears
The snippet adds an admin page under:
Tools → Media Images CSV Report
From there, the administrator can start the export process and download the final CSV file.
Where the CSV is stored
The CSV file is stored inside the WordPress uploads directory, in the following folder:
/wp-content/uploads/wg-media-reports/
This means the file becomes easy to access after export through a URL, provided the website allows public access to uploads.
What the code checks
The logic attempts to detect where each image is being used by checking:
- featured image references
- post content
- possible post meta references
Of course, because each website may use a different builder, custom field setup, plugin structure, or serialized data format, some cases may require additional adaptation. Still, the following implementation is a very solid base for real-world reporting.
The code
Below is the full code:
<?php
if (!defined('ABSPATH')) {
exit;
}
/**
* Admin page
*/
add_action('admin_menu', function () {
add_management_page(
'Media Images CSV Report',
'Media Images CSV Report',
'manage_options',
'wg-media-images-csv-report',
'wg_media_images_csv_report_admin_page_ajax'
);
});
function wg_media_images_csv_report_admin_page_ajax() {
if (!current_user_can('manage_options')) {
return;
}
$nonce = wp_create_nonce('wg_media_csv_ajax_nonce');
?>
<div class="wrap">
<h1>Media Images CSV Report</h1>
<p>Creates CSV in batches to avoid timeout.</p>
<p>
<button type="button" class="button button-primary" id="wg-start-media-report">
Start Export
</button>
</p>
<div id="wg-media-report-status" style="margin-top:15px;"></div>
<div style="width: 500px; max-width: 100%; background: #ddd; height: 24px; border-radius: 4px; overflow: hidden; margin-top: 15px;">
<div id="wg-media-report-bar" style="width:0%; height:100%; background:#2271b1; transition:width .3s ease;"></div>
</div>
<p id="wg-media-report-progress-text" style="margin-top:10px;"></p>
<script>
(function(){
const startBtn = document.getElementById('wg-start-media-report');
const statusBox = document.getElementById('wg-media-report-status');
const bar = document.getElementById('wg-media-report-bar');
const progressText = document.getElementById('wg-media-report-progress-text');
let sessionId = null;
let total = 0;
let processed = 0;
let running = false;
function setStatus(html) {
statusBox.innerHTML = html;
}
function setProgress(done, totalItems) {
const percent = totalItems > 0 ? Math.round((done / totalItems) * 100) : 0;
bar.style.width = percent + '%';
progressText.textContent = done + ' / ' + totalItems + ' images processed (' + percent + '%)';
}
function post(action, data = {}) {
const formData = new FormData();
formData.append('action', action);
formData.append('_ajax_nonce', '<?php echo esc_js($nonce); ?>');
Object.keys(data).forEach(function(key){
formData.append(key, data[key]);
});
return fetch(ajaxurl, {
method: 'POST',
credentials: 'same-origin',
body: formData
}).then(r => r.json());
}
function runBatch() {
post('wg_media_csv_process_batch', { session_id: sessionId })
.then(function(response){
if (!response || !response.success) {
const msg = response && response.data && response.data.message ? response.data.message : 'Unknown AJAX error.';
setStatus('<div class="notice notice-error"><p>' + msg + '</p></div>');
running = false;
startBtn.disabled = false;
return;
}
const data = response.data;
processed = parseInt(data.processed || 0, 10);
total = parseInt(data.total || 0, 10);
setProgress(processed, total);
if (data.done) {
let html = '<div class="notice notice-success"><p>Export completed.</p></div>';
if (data.download_url) {
html += '<p><a class="button button-primary" href="' + data.download_url + '" target="_blank">Download CSV</a></p>';
}
setStatus(html);
running = false;
startBtn.disabled = false;
return;
}
runBatch();
})
.catch(function(error){
console.error(error);
setStatus('<div class="notice notice-error"><p>Request failed.</p></div>');
running = false;
startBtn.disabled = false;
});
}
startBtn.addEventListener('click', function(){
if (running) return;
running = true;
startBtn.disabled = true;
setStatus('<div class="notice notice-info"><p>Preparing export...</p></div>');
bar.style.width = '0%';
progressText.textContent = '';
post('wg_media_csv_start')
.then(function(response){
if (!response || !response.success) {
const msg = response && response.data && response.data.message ? response.data.message : 'Could not start export.';
setStatus('<div class="notice notice-error"><p>' + msg + '</p></div>');
running = false;
startBtn.disabled = false;
return;
}
sessionId = response.data.session_id;
total = parseInt(response.data.total || 0, 10);
processed = 0;
setProgress(0, total);
setStatus('<div class="notice notice-info"><p>Export started...</p></div>');
runBatch();
})
.catch(function(error){
console.error(error);
setStatus('<div class="notice notice-error"><p>Failed to initialize export.</p></div>');
running = false;
startBtn.disabled = false;
});
});
})();
</script>
</div>
<?php
}
/**
* AJAX: start export
*/
add_action('wp_ajax_wg_media_csv_start', function () {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized'));
}
check_ajax_referer('wg_media_csv_ajax_nonce');
$attachments = get_posts(array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'post_status' => 'inherit',
'posts_per_page' => -1,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
));
if (!is_array($attachments)) {
$attachments = array();
}
$upload = wp_upload_dir();
if (!empty($upload['error'])) {
wp_send_json_error(array('message' => $upload['error']));
}
$dir = trailingslashit($upload['basedir']) . 'wg-media-reports';
if (!file_exists($dir)) {
wp_mkdir_p($dir);
}
if (!is_dir($dir) || !is_writable($dir)) {
wp_send_json_error(array('message' => 'Upload directory is not writable.'));
}
$session_id = 'wg_media_csv_' . wp_generate_password(12, false, false);
$file_name = 'media-library-original-images-report-' . date('Y-m-d-H-i-s') . '-' . $session_id . '.csv';
$file_path = trailingslashit($dir) . $file_name;
$file_url = trailingslashit($upload['baseurl']) . 'wg-media-reports/' . $file_name;
$fp = fopen($file_path, 'w');
if (!$fp) {
wp_send_json_error(array('message' => 'Could not create CSV file.'));
}
// UTF-8 BOM
fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($fp, array(
'Image URL',
'Filename',
'Alt Text',
'Appears In Title',
'Appears In URL',
'Appears In Type',
'Date Added',
));
fclose($fp);
set_transient('wg_media_csv_session_' . $session_id, array(
'attachment_ids' => $attachments,
'total' => count($attachments),
'offset' => 0,
'file_path' => $file_path,
'file_url' => $file_url,
'started_at' => time(),
), 6 * HOUR_IN_SECONDS);
wp_send_json_success(array(
'session_id' => $session_id,
'total' => count($attachments),
));
});
/**
* AJAX: process batch
*/
add_action('wp_ajax_wg_media_csv_process_batch', function () {
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized'));
}
check_ajax_referer('wg_media_csv_ajax_nonce');
$session_id = isset($_POST['session_id']) ? sanitize_text_field(wp_unslash($_POST['session_id'])) : '';
if (!$session_id) {
wp_send_json_error(array('message' => 'Missing session ID.'));
}
$data = get_transient('wg_media_csv_session_' . $session_id);
if (!$data || empty($data['attachment_ids']) || empty($data['file_path'])) {
wp_send_json_error(array('message' => 'Export session expired or not found.'));
}
$attachment_ids = $data['attachment_ids'];
$total = (int) $data['total'];
$offset = (int) $data['offset'];
$file_path = $data['file_path'];
$file_url = $data['file_url'];
$batch_size = 10;
$batch_ids = array_slice($attachment_ids, $offset, $batch_size);
if (!file_exists($file_path)) {
wp_send_json_error(array('message' => 'CSV file not found.'));
}
$fp = fopen($file_path, 'a');
if (!$fp) {
wp_send_json_error(array('message' => 'Could not open CSV file for writing.'));
}
foreach ($batch_ids as $attachment_id) {
$original_url = wg_media_report_get_original_image_url($attachment_id);
if (!$original_url) {
$offset++;
continue;
}
$filename_only = wg_media_report_get_attachment_filename($attachment_id);
$alt_text = wg_media_report_get_attachment_alt($attachment_id);
$date_added = wg_media_report_get_attachment_date($attachment_id);
$usages = wg_media_report_find_image_usages_for_single_attachment($attachment_id, $original_url);
if (empty($usages)) {
fputcsv($fp, array(
$original_url,
$filename_only,
$alt_text,
'',
'',
'',
$date_added,
));
} else {
foreach ($usages as $usage) {
fputcsv($fp, array(
$original_url,
$filename_only,
$alt_text,
$usage['title'],
$usage['url'],
$usage['type'],
$date_added,
));
}
}
$offset++;
}
fclose($fp);
$data['offset'] = $offset;
set_transient('wg_media_csv_session_' . $session_id, $data, 6 * HOUR_IN_SECONDS);
$done = ($offset >= $total);
if ($done) {
delete_transient('wg_media_csv_session_' . $session_id);
}
wp_send_json_success(array(
'processed' => $offset,
'total' => $total,
'done' => $done,
'download_url' => $done ? $file_url : '',
));
});
/**
* Helpers
*/
function wg_media_report_get_original_image_url($attachment_id) {
$url = wp_get_original_image_url($attachment_id);
if (!$url) {
$url = wp_get_attachment_url($attachment_id);
}
return $url ? $url : '';
}
function wg_media_report_get_attachment_alt($attachment_id) {
$alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
return is_string($alt) ? $alt : '';
}
function wg_media_report_get_attachment_filename($attachment_id) {
$file = get_attached_file($attachment_id);
if ($file) {
return wp_basename($file);
}
$url = wg_media_report_get_original_image_url($attachment_id);
return $url ? wp_basename(parse_url($url, PHP_URL_PATH)) : '';
}
function wg_media_report_get_attachment_date($attachment_id) {
$post = get_post($attachment_id);
if (!$post) {
return '';
}
return mysql2date('Y-m-d H:i:s', $post->post_date, false);
}
function wg_media_report_normalize_url_for_search($url) {
$url = trim((string) $url);
if ($url === '') {
return '';
}
$url = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$parts = wp_parse_url($url);
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
return $url;
}
$normalized = $parts['scheme'] . '://' . $parts['host'];
$normalized .= isset($parts['port']) ? ':' . $parts['port'] : '';
$normalized .= isset($parts['path']) ? $parts['path'] : '';
return $normalized;
}
function wg_media_report_get_url_variations($url) {
$variations = array();
$normalized = wg_media_report_normalize_url_for_search($url);
if ($normalized !== '') {
$variations[] = $normalized;
$parsed = wp_parse_url($normalized);
if (!empty($parsed['path'])) {
$variations[] = $parsed['path'];
$variations[] = ltrim($parsed['path'], '/');
}
}
return array_values(array_unique(array_filter($variations)));
}
function wg_media_report_post_content_has_image($post_content, $attachment_id, $url_variations) {
if (empty($post_content)) {
return false;
}
$content = (string) $post_content;
if (strpos($content, 'wp-image-' . $attachment_id) !== false) {
return true;
}
if (preg_match('/"id"\s*:\s*' . preg_quote((string) $attachment_id, '/') . '\b/', $content)) {
return true;
}
foreach ($url_variations as $needle) {
if ($needle !== '' && strpos($content, $needle) !== false) {
return true;
}
}
return false;
}
function wg_media_report_postmeta_has_image($meta_value, $attachment_id, $url_variations) {
if ($meta_value === '' || $meta_value === null) {
return false;
}
if (is_array($meta_value) || is_object($meta_value)) {
$meta_value = maybe_serialize($meta_value);
}
$meta_value = (string) $meta_value;
if ($meta_value === (string) $attachment_id) {
return true;
}
if (preg_match('/(^|[^0-9])' . preg_quote((string) $attachment_id, '/') . '([^0-9]|$)/', $meta_value)) {
return true;
}
foreach ($url_variations as $needle) {
if ($needle !== '' && strpos($meta_value, $needle) !== false) {
return true;
}
}
return false;
}
function wg_media_report_find_image_usages_for_single_attachment($attachment_id, $original_url) {
global $wpdb;
$usages = array();
$url_variations = wg_media_report_get_url_variations($original_url);
$post_ids = array();
$thumb_ids = $wpdb->get_col($wpdb->prepare(
"SELECT post_id
FROM {$wpdb->postmeta}
WHERE meta_key = '_thumbnail_id'
AND meta_value = %s",
(string) $attachment_id
));
if (!empty($thumb_ids)) {
foreach ($thumb_ids as $pid) {
$post_ids[(int) $pid] = 'featured_image';
}
}
$content_conditions = array();
$content_params = array();
$content_conditions[] = "post_content LIKE %s";
$content_params[] = '%' . $wpdb->esc_like('wp-image-' . $attachment_id) . '%';
$content_conditions[] = "post_content LIKE %s";
$content_params[] = '%' . $wpdb->esc_like('"id":' . $attachment_id) . '%';
$content_conditions[] = "post_content LIKE %s";
$content_params[] = '%' . $wpdb->esc_like('"id": ' . $attachment_id) . '%';
foreach ($url_variations as $variation) {
$content_conditions[] = "post_content LIKE %s";
$content_params[] = '%' . $wpdb->esc_like($variation) . '%';
}
$sql = "SELECT ID, post_type, post_title
FROM {$wpdb->posts}
WHERE post_status IN ('publish','private','draft','future','pending')
AND (" . implode(' OR ', $content_conditions) . ")";
$prepared = $wpdb->prepare($sql, $content_params);
$content_posts = $wpdb->get_results($prepared);
if (!empty($content_posts)) {
foreach ($content_posts as $post) {
$post_ids[(int) $post->ID] = 'post_content';
}
}
$meta_conditions = array();
$meta_params = array();
$meta_conditions[] = "meta_value = %s";
$meta_params[] = (string) $attachment_id;
$meta_conditions[] = "meta_value LIKE %s";
$meta_params[] = '%' . $wpdb->esc_like((string) $attachment_id) . '%';
foreach ($url_variations as $variation) {
$meta_conditions[] = "meta_value LIKE %s";
$meta_params[] = '%' . $wpdb->esc_like($variation) . '%';
}
$meta_sql = "SELECT DISTINCT post_id
FROM {$wpdb->postmeta}
WHERE meta_key NOT IN ('_edit_lock','_edit_last')
AND (" . implode(' OR ', $meta_conditions) . ")";
$meta_prepared = $wpdb->prepare($meta_sql, $meta_params);
$meta_post_ids = $wpdb->get_col($meta_prepared);
if (!empty($meta_post_ids)) {
foreach ($meta_post_ids as $pid) {
if (!isset($post_ids[(int) $pid])) {
$post_ids[(int) $pid] = 'postmeta';
}
}
}
if (empty($post_ids)) {
return array();
}
$posts = get_posts(array(
'post_type' => 'any',
'post_status' => array('publish', 'private', 'draft', 'future', 'pending'),
'posts_per_page' => -1,
'post__in' => array_keys($post_ids),
));
foreach ($posts as $post) {
$match_type = $post_ids[(int) $post->ID];
$permalink = get_permalink($post->ID);
if (!$permalink) {
$permalink = admin_url('post.php?post=' . $post->ID . '&action=edit');
}
$usages[] = array(
'title' => get_the_title($post->ID),
'url' => $permalink,
'type' => $post->post_type . ' (' . $match_type . ')',
);
}
return $usages;
}
Performance notes
If the export still feels heavy on a specific server, the first thing worth reducing is the batch size:
$batch_size = 10;
On stricter hosting environments, it can be lowered to:
$batch_size = 3;
This increases the total number of AJAX requests, but reduces the load per request and helps significantly on websites with many attachments or large amounts of metadata.
Useful notes
- The tool targets original images, not resized variants.
- The final CSV can be used for content audits or estimate / quotation processes.
- If a website uses heavily custom builders or complex serialized structures, some custom adaptation may still be needed.
- The AJAX workflow is usually a much safer option than trying to generate the entire export in a single request.
Conclusion
This approach is especially useful when you need to deliver a structured image report for a WordPress website without relying on a heavy single-request script. With batching, progress indication, and CSV output inside the uploads directory, the whole process becomes much more practical and server-friendly.
Credits: Nicolas Lagios & ChatGPT
This post is also available in:
Ελληνικά
