Loading 0
Share

My Blog

Scroll Down

WordPress Media Library CSV Report with AJAX Export for Original Images

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: Ελληνικά

Leave a Reply

Your email address will not be published. Required fields are marked *

01.

Here you can see all the services I provide

Registration and management of domain names (website address such as www.nicolaslagios.com)

Also management of dns records (e.g. connecting the domain to a specific server, fixing email spam problems, etc.)

Also ssl renewals etc

Installation and management of web & mail server in ubuntu vps with virtualmin, plesk, cpanel

Also studying and fixing server problems.

Necessary condition, the target server meets the conditions

At the moment for new wordpress websites you can choose from ready-made themes and we change the content (no custom changes). You can buy with a fixed price by clicking here!

My team and I undertake any data bridging implementation for Wordpress, Prestashop, Opencart, Joomla platforms.

We can connect data from any source, as long as the structure is stable and there is proper documentation and briefing.

We undertake the creation, regulation and enrichment of pages for social networks: Facebook, Linkedin, Instagram (profile), Twitter (profile), Tiktok (profile).

We also undertake the first boost of your pages for quick results in followers.

We undertake the repair and maintenance of your existing wordpress website.

For more information about the services, you can read the following and return here to schedule a meeting with me: https://maxservices.gr/en/internet-services/website-services-blank/additional-website-services/