admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE_ACTION), ]); } public static function render_admin_page(): void { if (!current_user_can('manage_options')) { wp_die('You do not have permission to access this page.'); } ?>

Bulk Media Attributes (CSV → existing Media Library)

Upload a CSV that includes at minimum image_url and alt_text. Optional columns: title, caption, description. Other columns (like content_type) are ignored.


1) Upload CSV


2) Progress

Status: Idle
Processed: 0 / 0   |   Updated: 0   |   Not found: 0   |   Errors: 0

Latest results

'No file uploaded.'], 400); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified, file handled by wp_handle_upload $file = $_FILES['file']; if (!function_exists('wp_handle_upload')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $overrides = [ 'test_form' => false, 'mimes' => [ 'csv' => 'text/csv', 'txt' => 'text/plain', ], ]; $uploaded = wp_handle_upload($file, $overrides); if (isset($uploaded['error'])) { wp_send_json_error(['message' => $uploaded['error']], 400); } $path = $uploaded['file']; $job_id = self::make_job_id(get_current_user_id()); $meta = [ 'path' => $path, 'created_at' => time(), 'user_id' => get_current_user_id(), ]; set_transient(self::job_key($job_id), $meta, 6 * HOUR_IN_SECONDS); // Count rows (best effort) to show progress $total = self::count_csv_rows($path); wp_send_json_success([ 'job_id' => $job_id, 'total' => $total, ]); } /* ========================= * AJAX: process batch * ========================= */ public static function ajax_process_batch(): void { self::require_admin_and_nonce(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $job_id = isset($_POST['job_id']) ? sanitize_text_field(wp_unslash($_POST['job_id'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $cursor = isset($_POST['cursor']) ? intval($_POST['cursor']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $batch_size = isset($_POST['batch_size']) ? max(1, min(500, intval($_POST['batch_size']))) : 25; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $update_alt = !empty($_POST['update_alt']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $update_title = !empty($_POST['update_title']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $update_caption = !empty($_POST['update_caption']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $update_description = !empty($_POST['update_description']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $dry_run = !empty($_POST['dry_run']); if (!$job_id) { wp_send_json_error(['message' => 'Missing job_id'], 400); } $meta = get_transient(self::job_key($job_id)); if (!$meta || empty($meta['path']) || !file_exists($meta['path'])) { wp_send_json_error(['message' => 'Job not found or file missing.'], 404); } // Only the creator can run it if (intval($meta['user_id']) !== get_current_user_id()) { wp_send_json_error(['message' => 'Not allowed.'], 403); } $path = $meta['path']; [$results, $next_cursor, $done] = self::process_csv_batch( $path, $cursor, $batch_size, [ 'update_alt' => $update_alt, 'update_title' => $update_title, 'update_caption' => $update_caption, 'update_description' => $update_description, 'dry_run' => $dry_run, ] ); wp_send_json_success([ 'results' => $results, 'next_cursor' => $next_cursor, 'done' => $done, ]); } public static function ajax_cleanup_job(): void { self::require_admin_and_nonce(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in require_admin_and_nonce() $job_id = isset($_POST['job_id']) ? sanitize_text_field(wp_unslash($_POST['job_id'])) : ''; if (!$job_id) { wp_send_json_error(['message' => 'Missing job_id'], 400); } $meta = get_transient(self::job_key($job_id)); if ($meta && !empty($meta['path']) && file_exists($meta['path'])) { // best effort delete wp_delete_file($meta['path']); } delete_transient(self::job_key($job_id)); wp_send_json_success(['message' => 'Cleaned up']); } /* ========================= * Core batch processor * ========================= */ private static function process_csv_batch(string $path, int $cursor, int $batch_size, array $opts): array { $file = new SplFileObject($path, 'r'); $file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE); // Header is on line 0; data begins at line 1 $header = null; $map = []; $file->rewind(); if (!$file->eof()) { $header = $file->fgetcsv(); } if (!is_array($header)) { return [[['type' => 'error', 'message' => 'CSV header missing or unreadable.']], $cursor, true]; } $map = self::build_header_map($header); // We expect cursor to be "data row index" (starting at 0 for first data row) // Translate to file line: header=0, first data row=1 => line = cursor + 1 $start_line = $cursor + 1; $file->seek($start_line); $processed = 0; $updated = 0; $notfound = 0; $errors = 0; $logs = []; while (!$file->eof() && $processed < $batch_size) { $row = $file->current(); $file->next(); // Sometimes SplFileObject returns [null] at EOF-ish if (!is_array($row) || (count($row) === 1 && $row[0] === null)) { continue; } $processed++; $url = self::get_col($row, $map, ['image_url', 'url', 'image', 'src']); $alt = self::get_col($row, $map, ['alt_text', 'alt', 'alttext']); $title = self::get_col($row, $map, ['title']); $caption = self::get_col($row, $map, ['caption']); $description = self::get_col($row, $map, ['description', 'desc']); $url = trim((string)$url); if ($url === '') { $errors++; $logs[] = ['type' => 'error', 'message' => 'Row missing image_url']; continue; } $attachment_id = self::find_attachment_id_by_url($url); if (!$attachment_id) { $notfound++; $logs[] = ['type' => 'notfound', 'message' => 'Not found: ' . esc_html($url)]; continue; } $did_update_this = false; if (!empty($opts['update_alt']) && $alt !== null && $alt !== '') { if (empty($opts['dry_run'])) { update_post_meta($attachment_id, '_wp_attachment_image_alt', wp_strip_all_tags((string)$alt)); } $did_update_this = true; } $post_update = ['ID' => $attachment_id]; $has_post_update = false; if (!empty($opts['update_title']) && $title !== null && $title !== '') { $post_update['post_title'] = sanitize_text_field((string)$title); $has_post_update = true; } if (!empty($opts['update_caption']) && $caption !== null && $caption !== '') { $post_update['post_excerpt'] = wp_kses_post((string)$caption); $has_post_update = true; } if (!empty($opts['update_description']) && $description !== null && $description !== '') { $post_update['post_content'] = wp_kses_post((string)$description); $has_post_update = true; } if ($has_post_update) { if (empty($opts['dry_run'])) { $r = wp_update_post($post_update, true); if (is_wp_error($r)) { $errors++; $logs[] = ['type' => 'error', 'message' => 'Update failed for ID ' . intval($attachment_id) . ': ' . esc_html($r->get_error_message())]; continue; } } $did_update_this = true; } if ($did_update_this) { $updated++; $logs[] = [ 'type' => 'updated', 'message' => ($opts['dry_run'] ? 'Would update' : 'Updated') . ' attachment ID ' . intval($attachment_id) . ' from ' . esc_html($url), ]; } else { // Nothing to change (missing selected columns/values) $logs[] = [ 'type' => 'skipped', 'message' => 'Skipped (no selected values) for ID ' . intval($attachment_id) . ' from ' . esc_html($url), ]; } } // Next cursor is previous cursor + number of DATA rows processed (not counting header) $next_cursor = $cursor + $processed; // Determine done: if we reached EOF after attempting reads $done = $file->eof(); // Trim logs to keep responses small if (count($logs) > 50) { $logs = array_slice($logs, -50); } $results = [ 'processed' => $processed, 'updated' => $updated, 'notfound' => $notfound, 'errors' => $errors, 'logs' => $logs, ]; return [$results, $next_cursor, $done]; } /* ========================= * Helpers * ========================= */ private static function require_admin_and_nonce(): void { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Forbidden'], 403); } $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!$nonce || !wp_verify_nonce($nonce, self::NONCE_ACTION)) { wp_send_json_error(['message' => 'Bad nonce'], 403); } } private static function make_job_id(int $user_id): string { return 'job_' . $user_id . '_' . wp_generate_password(10, false, false); } private static function job_key(string $job_id): string { return 'bma1705_' . $job_id; } private static function count_csv_rows(string $path): int { $count = 0; try { $f = new SplFileObject($path, 'r'); $f->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE); $f->rewind(); // header if (!$f->eof()) { $f->fgetcsv(); } while (!$f->eof()) { $row = $f->fgetcsv(); if (!is_array($row) || (count($row) === 1 && $row[0] === null)) { continue; } // require at least one non-empty cell to count $has = false; foreach ($row as $cell) { if (trim((string)$cell) !== '') { $has = true; break; } } if ($has) { $count++; } } } catch (Throwable $e) { // ignore } return $count; } private static function build_header_map(array $header): array { $map = []; foreach ($header as $i => $name) { $key = strtolower(trim((string)$name)); if ($key !== '') { $map[$key] = $i; } } return $map; } private static function get_col(array $row, array $map, array $names) { foreach ($names as $n) { $k = strtolower($n); if (isset($map[$k])) { $idx = $map[$k]; return isset($row[$idx]) ? $row[$idx] : null; } } return null; } private static function find_attachment_id_by_url(string $url): int { $url = self::strip_query_fragment($url); // First try: exact $id = attachment_url_to_postid($url); if ($id) { return (int)$id; } // Try normalized variants $norm = self::normalize_media_url($url); if ($norm !== $url) { $id = attachment_url_to_postid($norm); if ($id) { return (int)$id; } } // Fallback: derive relative uploads path and query by _wp_attached_file $rel = self::url_to_uploads_relative_path($norm ?: $url); if ($rel) { $id = self::find_attachment_id_by_attached_file($rel); if ($id) { return (int)$id; } // Try normalized relative (strip size suffix etc.) $rel2 = self::normalize_attached_file($rel); if ($rel2 !== $rel) { $id = self::find_attachment_id_by_attached_file($rel2); if ($id) { return (int)$id; } } } // Final fallback: try basename search $base = wp_basename($norm ?: $url); if ($base) { $base2 = self::normalize_filename($base); $id = self::find_attachment_id_by_basename($base2); if ($id) { return (int)$id; } } return 0; } private static function strip_query_fragment(string $url): string { $url = preg_replace('/[#?].*$/', '', $url); return $url ?: ''; } /** * Normalizes common WP-generated variants: * - removes -123x456 before extension * - removes -scaled * - removes -e1234567890123 (WP image edit suffix) */ private static function normalize_media_url(string $url): string { $url = self::strip_query_fragment($url); $parts = wp_parse_url($url); if (empty($parts['path'])) { return $url; } $path = $parts['path']; // remove -scaled $path = preg_replace('/-scaled(\.[a-zA-Z0-9]+)$/', '$1', $path); // remove -e123... just before extension $path = preg_replace('/-e\d+(\.[a-zA-Z0-9]+)$/', '$1', $path); // remove -123x456 before extension $path = preg_replace('/-\d+x\d+(\.[a-zA-Z0-9]+)$/', '$1', $path); // rebuild URL (preserve scheme/host) $scheme = $parts['scheme'] ?? ''; $host = $parts['host'] ?? ''; $port = isset($parts['port']) ? ':' . $parts['port'] : ''; $prefix = ($scheme && $host) ? ($scheme . '://' . $host . $port) : ''; return $prefix . $path; } private static function url_to_uploads_relative_path(string $url): string { $url = self::strip_query_fragment($url); $uploads = wp_get_upload_dir(); if (empty($uploads['baseurl'])) { return ''; } $baseurl = rtrim($uploads['baseurl'], '/'); if (strpos($url, $baseurl) !== 0) { return ''; } $rel = ltrim(substr($url, strlen($baseurl)), '/'); return $rel ?: ''; } private static function normalize_attached_file(string $rel): string { // For _wp_attached_file values: 2025/09/foo-123x456.webp, foo-scaled.webp, foo-e123.webp $dir = trailingslashit(dirname($rel)); $file = wp_basename($rel); $file = self::normalize_filename($file); if ($dir === './' || $dir === '/' ) { $dir = ''; } return $dir . $file; } private static function normalize_filename(string $filename): string { $filename = preg_replace('/-scaled(\.[a-zA-Z0-9]+)$/', '$1', $filename); $filename = preg_replace('/-e\d+(\.[a-zA-Z0-9]+)$/', '$1', $filename); $filename = preg_replace('/-\d+x\d+(\.[a-zA-Z0-9]+)$/', '$1', $filename); return $filename ?: ''; } private static function find_attachment_id_by_attached_file(string $rel): int { global $wpdb; $rel = ltrim($rel, '/'); if ($rel === '') { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1", $rel ) ); return $id ? (int)$id : 0; } private static function find_attachment_id_by_basename(string $basename): int { global $wpdb; if ($basename === '') { return 0; } // Look for meta_value ending in /basename OR exactly basename $like1 = '%/' . $wpdb->esc_like($basename); $like2 = $wpdb->esc_like($basename); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND (meta_value LIKE %s OR meta_value = %s) LIMIT 1", $like1, $like2 ) ); return $id ? (int)$id : 0; } } BMA1705_Bulk_Media_Attributes::init();