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
'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();