PluginProbe ʕ •ᴥ•ʔ
Export/Import Media – CSV Media Library Import & Export / 1.7.28
Export/Import Media – CSV Media Library Import & Export v1.7.28
1.7.28 1.7.27 1.7.20 1.7.26 1.7.10 1.7.9 1.7.1 1.7 trunk 1.0 1.0.3 1.2.1 1.2.2 1.2.3 1.6.15 1.6.4
calliope-media-import-export / includes / class-importer.php
calliope-media-import-export / includes Last commit date
class-config.php 1 week ago class-eim-service-registry.php 1 week ago class-exporter.php 1 week ago class-importer.php 1 week ago
class-importer.php
3990 lines
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 class EIM_Importer {
7
8 const MAX_BATCH_SIZE = 50;
9 const TEMP_FILE_TTL = DAY_IN_SECONDS;
10 const LOCK_TTL = 90;
11
12 public function __construct() {
13 add_action( 'wp_ajax_eim_validate_csv', [ $this, 'validate_csv' ] );
14 add_action( 'wp_ajax_eim_process_batch', [ $this, 'process_batch' ] );
15 add_action( 'wp_ajax_eim_get_import_progress', [ $this, 'get_import_progress' ] );
16 add_action( 'eim_daily_cleanup_event', [ $this, 'cleanup_temp_files' ] );
17 }
18
19 public static function activate_plugin() {
20 $installed_at_option = defined( 'EIM_INSTALLED_AT_OPTION' ) ? EIM_INSTALLED_AT_OPTION : 'eim_installed_at';
21 if ( false === get_option( $installed_at_option, false ) ) {
22 add_option( $installed_at_option, time(), '', false );
23 }
24
25 if ( ! wp_next_scheduled( 'eim_daily_cleanup_event' ) ) {
26 wp_schedule_event( time(), 'daily', 'eim_daily_cleanup_event' );
27 }
28 }
29
30 public static function deactivate_plugin() {
31 wp_clear_scheduled_hook( 'eim_daily_cleanup_event' );
32 }
33
34 public function validate_csv() {
35 $this->ensure_ajax_permissions();
36
37 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
38 if ( empty( $_FILES['eim_csv'] ) || empty( $_FILES['eim_csv']['tmp_name'] ) ) {
39 wp_send_json_error( [ 'message' => __( 'No file uploaded.', 'calliope-media-import-export' ) ], 400 );
40 }
41
42 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
43 $upload_error = isset( $_FILES['eim_csv']['error'] ) ? absint( $_FILES['eim_csv']['error'] ) : UPLOAD_ERR_OK;
44 if ( UPLOAD_ERR_OK !== $upload_error ) {
45 wp_send_json_error( [ 'message' => $this->get_upload_error_message( $upload_error ) ], 400 );
46 }
47
48 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- PHP generates uploaded temp paths; sanitizing or unslashing can corrupt Windows paths.
49 $tmp_name = is_string( $_FILES['eim_csv']['tmp_name'] ) ? $_FILES['eim_csv']['tmp_name'] : '';
50 if ( ! is_string( $tmp_name ) || '' === $tmp_name || ( ! is_uploaded_file( $tmp_name ) && ! file_exists( $tmp_name ) ) ) {
51 wp_send_json_error( [ 'message' => __( 'Error uploading file.', 'calliope-media-import-export' ) ], 400 );
52 }
53
54 $inspection = $this->inspect_csv_path( $tmp_name );
55 if ( is_wp_error( $inspection ) ) {
56 wp_send_json_error( [ 'message' => $inspection->get_error_message() ], 400 );
57 }
58
59 $temp_file = $this->create_temp_import_file( $tmp_name, $inspection );
60 if ( is_wp_error( $temp_file ) ) {
61 wp_send_json_error( [ 'message' => $temp_file->get_error_message() ], 500 );
62 }
63
64 wp_send_json_success(
65 [
66 'file' => $temp_file['file'],
67 'total_rows' => $inspection['total_rows'],
68 'preview' => $this->build_validation_preview( $inspection ),
69 ]
70 );
71 }
72
73 public function get_import_progress() {
74 $this->ensure_ajax_permissions();
75
76 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AJAX request is verified in ensure_ajax_permissions().
77 $file_name = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : '';
78 $after = $this->get_request_absint( 'after' );
79 $paths = $this->get_temp_file_paths( $file_name );
80
81 if ( is_wp_error( $paths ) ) {
82 wp_send_json_error( [ 'message' => $paths->get_error_message() ], 400 );
83 }
84
85 if ( ! file_exists( $paths['progress'] ) ) {
86 wp_send_json_success(
87 [
88 'entries' => [],
89 'latest_cursor' => $after,
90 ]
91 );
92 }
93
94 $entries = [];
95 $latest_cursor = $after;
96 $handle = $this->open_read_handle( $paths['progress'] );
97
98 if ( $handle ) {
99 while ( ! feof( $handle ) ) {
100 $line = fgets( $handle );
101 if ( false === $line || '' === trim( $line ) ) {
102 continue;
103 }
104
105 $entry = json_decode( $line, true );
106 if ( ! is_array( $entry ) ) {
107 continue;
108 }
109
110 $cursor = isset( $entry['cursor'] ) ? absint( $entry['cursor'] ) : 0;
111 if ( $cursor <= 0 ) {
112 continue;
113 }
114
115 $latest_cursor = max( $latest_cursor, $cursor );
116 if ( $cursor <= $after ) {
117 continue;
118 }
119
120 $entries[] = [
121 'cursor' => $cursor,
122 'result' => isset( $entry['result'] ) && is_array( $entry['result'] ) ? $entry['result'] : [],
123 ];
124
125 if ( count( $entries ) >= 100 ) {
126 break;
127 }
128 }
129
130 $this->close_file_handle( $handle );
131 }
132
133 wp_send_json_success(
134 [
135 'entries' => $entries,
136 'latest_cursor' => $latest_cursor,
137 ]
138 );
139 }
140
141 private function get_upload_error_message( $upload_error ) {
142 $messages = [
143 UPLOAD_ERR_INI_SIZE => __( 'The uploaded file exceeds the server upload limit.', 'calliope-media-import-export' ),
144 UPLOAD_ERR_FORM_SIZE => __( 'The uploaded file exceeds the form upload limit.', 'calliope-media-import-export' ),
145 UPLOAD_ERR_PARTIAL => __( 'The uploaded file was only partially uploaded.', 'calliope-media-import-export' ),
146 UPLOAD_ERR_NO_FILE => __( 'No file uploaded.', 'calliope-media-import-export' ),
147 UPLOAD_ERR_NO_TMP_DIR => __( 'The server temporary upload folder is missing.', 'calliope-media-import-export' ),
148 UPLOAD_ERR_CANT_WRITE => __( 'The uploaded file could not be written to disk.', 'calliope-media-import-export' ),
149 UPLOAD_ERR_EXTENSION => __( 'A server extension stopped the file upload.', 'calliope-media-import-export' ),
150 ];
151
152 return isset( $messages[ $upload_error ] ) ? $messages[ $upload_error ] : __( 'Error uploading file.', 'calliope-media-import-export' );
153 }
154
155 public function process_batch() {
156 $this->ensure_ajax_permissions();
157
158 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AJAX request is verified in ensure_ajax_permissions().
159 $file_name = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : '';
160 $start_row = $this->get_request_absint( 'start_row' );
161 $batch_size = $this->get_bounded_batch_size();
162 $time_limit = $this->get_batch_time_limit( $batch_size );
163 $start_time = time();
164 $local_import = $this->get_request_bool( 'local_import' );
165 $skip_thumbnails = $this->get_request_bool( 'skip_thumbnails' );
166 $honor_rel_path = $this->get_request_bool( 'honor_relative_path', true );
167 $results = [];
168 $handle = null;
169 $thumbs_disabled = false;
170 $error_message = '';
171 $lock_key = $this->get_temp_lock_key( $file_name );
172 $batch_summary = $this->get_empty_result_summary();
173 $processed_batch = 0;
174 $next_row = $start_row;
175 $total_rows = 0;
176 $is_finished = false;
177 $reached_eof = false;
178 $time_limited = false;
179 $request_context = $this->normalize_import_request_context(
180 [
181 'start_row' => $start_row,
182 'batch_size' => $batch_size,
183 'local_import' => $local_import,
184 'skip_thumbnails' => $skip_thumbnails,
185 'honor_relative_path' => $honor_rel_path,
186 'dry_run' => $this->get_request_bool( 'dry_run' ),
187 'duplicate_strategy' => $this->get_request_string( 'duplicate_strategy', 'skip' ),
188 'match_strategy' => $this->get_request_string( 'match_strategy', 'auto' ),
189 'selected_update_fields' => $this->get_request_array( 'selected_update_fields' ),
190 'pro_history_id' => $this->get_request_absint( 'pro_history_id' ),
191 'pro_job_id' => $this->get_request_absint( 'pro_job_id' ),
192 'convert_images_format' => $this->get_request_string( 'convert_images_format', 'keep' ),
193 'conversion_quality' => $this->get_request_absint( 'conversion_quality' ),
194 'conversion_failure_behavior' => $this->get_request_string( 'conversion_failure_behavior', 'keep_original' ),
195 'source' => 'ajax',
196 'file' => $file_name,
197 ]
198 );
199
200 $batch_size = $request_context['batch_size'];
201 $time_limit = $this->get_batch_time_limit_for_context( $this->get_batch_time_limit( $batch_size ), $request_context );
202 $this->extend_server_time_limit( $time_limit );
203 $local_import = $request_context['local_import'];
204 $skip_thumbnails = $request_context['skip_thumbnails'];
205 $honor_rel_path = $request_context['honor_relative_path'];
206
207 $this->log_import_event(
208 'batch_start',
209 [
210 'file' => $file_name,
211 'start_row' => $start_row,
212 'batch_size' => $batch_size,
213 'time_limit' => $time_limit,
214 'download_timeout' => $this->get_download_timeout( $request_context ),
215 'local_import' => $local_import,
216 'skip_thumbnails' => $skip_thumbnails,
217 'honor_relative_path' => $honor_rel_path,
218 'dry_run' => ! empty( $request_context['dry_run'] ),
219 'source' => 'ajax',
220 ]
221 );
222
223 $paths = $this->get_temp_file_paths( $file_name );
224 if ( is_wp_error( $paths ) ) {
225 $this->send_batch_error( $paths->get_error_message(), 400 );
226 }
227
228 if ( 0 === $start_row ) {
229 $this->reset_import_progress_log( $file_name );
230 }
231
232 if ( ! file_exists( $paths['csv'] ) ) {
233 $this->send_batch_error( __( 'Temporary file not found. Please upload the CSV again.', 'calliope-media-import-export' ), 404 );
234 }
235
236 if ( ! $this->acquire_temp_lock( $lock_key, $this->get_lock_ttl( $time_limit ) ) ) {
237 $this->send_batch_error( __( 'Another import request is already processing this file. Please wait a moment and try again.', 'calliope-media-import-export' ), 409 );
238 }
239
240 try {
241 $meta = $this->read_temp_file_meta( $file_name );
242 if ( is_wp_error( $meta ) ) {
243 throw new RuntimeException( $meta->get_error_message() );
244 }
245
246 $delimiter = isset( $meta['delimiter'] ) ? (string) $meta['delimiter'] : ',';
247 $total_rows = isset( $meta['total_rows'] ) ? absint( $meta['total_rows'] ) : 0;
248
249 if ( $total_rows > 0 && $start_row >= $total_rows ) {
250 $this->cleanup_temp_import_file( $file_name );
251 $results[] = [ 'status' => 'FINISHED' ];
252 $is_finished = true;
253 $reached_eof = true;
254 }
255
256 if ( empty( $results ) ) {
257 $handle = $this->open_read_handle( $paths['csv'] );
258 if ( ! $handle ) {
259 throw new RuntimeException( __( 'Could not open the temporary CSV file.', 'calliope-media-import-export' ) );
260 }
261
262 $headers = $this->read_csv_row( $handle, $delimiter, true );
263 if ( false === $headers ) {
264 throw new RuntimeException( __( 'Could not read the CSV headers.', 'calliope-media-import-export' ) );
265 }
266
267 $header_map = $this->map_headers( $headers );
268 if ( ! isset( $header_map['url'] ) && ! isset( $header_map['rel_path'] ) ) {
269 throw new RuntimeException( __( 'Invalid CSV. Missing "Absolute URL" or "Relative Path" column.', 'calliope-media-import-export' ) );
270 }
271
272 $skipped_rows = 0;
273 while ( $skipped_rows < $start_row ) {
274 $row_data = $this->read_csv_row( $handle, $delimiter );
275 if ( false === $row_data ) {
276 $reached_eof = true;
277 break;
278 }
279
280 if ( $this->is_csv_row_empty( $row_data ) ) {
281 continue;
282 }
283
284 $skipped_rows++;
285 }
286
287 if ( $skip_thumbnails ) {
288 add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );
289 $thumbs_disabled = true;
290 }
291
292 $current_row = $start_row;
293
294 while ( $processed_batch < $batch_size ) {
295 if ( $this->should_stop_batch_before_next_row( $processed_batch, $start_time, $time_limit, $request_context ) ) {
296 $time_limited = true;
297 break;
298 }
299
300 $row_data = $this->read_csv_row( $handle, $delimiter );
301 if ( false === $row_data ) {
302 $reached_eof = true;
303 break;
304 }
305
306 if ( $this->is_csv_row_empty( $row_data ) ) {
307 continue;
308 }
309
310 $current_row++;
311 $row = $this->build_row_from_csv( $row_data, $header_map );
312 $result = $this->process_single_item( $row, $local_import, $honor_rel_path, $request_context );
313
314 $result['row_number'] = $current_row;
315 if ( isset( $result['file'] ) ) {
316 $result['file'] = '#' . $current_row . ' - ' . $result['file'];
317 }
318
319 $this->append_import_progress( $file_name, $result );
320
321 $results[] = $result;
322 $batch_summary = $this->increment_result_summary( $batch_summary, $result );
323 $processed_batch++;
324 }
325
326 $next_row = $start_row + $processed_batch;
327 $is_finished = ( $total_rows > 0 && $next_row >= $total_rows ) || $reached_eof;
328
329 if ( $is_finished ) {
330 $this->cleanup_temp_import_file( $file_name );
331 }
332 }
333 } catch ( Exception $exception ) {
334 $error_message = $exception->getMessage();
335 $this->log_import_event(
336 'batch_exception',
337 [
338 'file' => $file_name,
339 'start_row' => $start_row,
340 'message' => $error_message,
341 ]
342 );
343 }
344
345 if ( $thumbs_disabled ) {
346 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );
347 }
348
349 if ( is_resource( $handle ) ) {
350 fclose( $handle );
351 }
352
353 $this->release_temp_lock( $lock_key );
354
355 if ( '' !== $error_message ) {
356 $this->send_batch_error( $error_message );
357 }
358
359 $response = $this->build_batch_response(
360 $results,
361 $batch_summary,
362 [
363 'start_row' => $start_row,
364 'next_row' => $next_row,
365 'processed_rows' => $processed_batch,
366 'batch_size' => $batch_size,
367 'total_rows' => $total_rows,
368 'is_finished' => $is_finished,
369 'time_limited' => $time_limited,
370 'time_limit' => $time_limit,
371 'local_import' => $local_import,
372 'skip_thumbnails' => $skip_thumbnails,
373 'honor_rel_path' => $honor_rel_path,
374 'dry_run' => ! empty( $request_context['dry_run'] ),
375 'duplicate_strategy' => $request_context['duplicate_strategy'],
376 'pro_history_id' => isset( $request_context['pro_history_id'] ) ? absint( $request_context['pro_history_id'] ) : 0,
377 'pro_job_id' => isset( $request_context['pro_job_id'] ) ? absint( $request_context['pro_job_id'] ) : 0,
378 'convert_images_format' => isset( $request_context['convert_images_format'] ) ? (string) $request_context['convert_images_format'] : 'keep',
379 'file' => $file_name,
380 ]
381 );
382
383 $this->log_import_event(
384 'batch_finish',
385 [
386 'file' => $file_name,
387 'start_row' => $start_row,
388 'next_row' => $next_row,
389 'processed_rows' => $processed_batch,
390 'summary' => $batch_summary,
391 'time_limited' => $time_limited,
392 'is_finished' => $is_finished,
393 ]
394 );
395
396 wp_send_json_success( $response );
397 }
398
399 public function inspect_csv_path( $file_path ) {
400 return $this->inspect_csv_file( $file_path );
401 }
402
403 public function run_import_from_path( $file_path, $args = [] ) {
404 $context = $this->normalize_import_request_context( $args );
405 $time_limit = $this->get_batch_time_limit_for_context( $this->get_batch_time_limit( $context['batch_size'] ), $context );
406 $this->extend_server_time_limit( $time_limit );
407
408 if ( ! is_string( $file_path ) || '' === $file_path || ! file_exists( $file_path ) ) {
409 return new WP_Error( 'eim_import_file_missing', __( 'Import file not found.', 'calliope-media-import-export' ) );
410 }
411
412 $inspection = $this->inspect_csv_path( $file_path );
413 if ( is_wp_error( $inspection ) ) {
414 return $inspection;
415 }
416
417 $handle = $this->open_read_handle( $file_path );
418 if ( ! $handle ) {
419 return new WP_Error( 'eim_import_file_unreadable', __( 'Could not open the import file.', 'calliope-media-import-export' ) );
420 }
421
422 $results = [];
423 $summary = $this->get_empty_result_summary();
424 $processed_batch = 0;
425 $reached_eof = false;
426 $is_finished = false;
427 $thumbs_disabled = false;
428 $start_row = $context['start_row'];
429 $next_row = $start_row;
430 $total_rows = isset( $inspection['total_rows'] ) ? absint( $inspection['total_rows'] ) : 0;
431 $delimiter = isset( $inspection['delimiter'] ) ? (string) $inspection['delimiter'] : ',';
432 $start_time = time();
433 $time_limited = false;
434
435 try {
436 $headers = $this->read_csv_row( $handle, $delimiter, true );
437 if ( false === $headers ) {
438 throw new RuntimeException( __( 'Could not read the CSV headers.', 'calliope-media-import-export' ) );
439 }
440
441 $header_map = $this->map_headers( $headers );
442 if ( ! isset( $header_map['url'] ) && ! isset( $header_map['rel_path'] ) ) {
443 throw new RuntimeException( __( 'Invalid CSV. Missing "Absolute URL" or "Relative Path" column.', 'calliope-media-import-export' ) );
444 }
445
446 $skipped_rows = 0;
447 while ( $skipped_rows < $start_row ) {
448 $row_data = $this->read_csv_row( $handle, $delimiter );
449 if ( false === $row_data ) {
450 $reached_eof = true;
451 break;
452 }
453
454 if ( $this->is_csv_row_empty( $row_data ) ) {
455 continue;
456 }
457
458 $skipped_rows++;
459 }
460
461 if ( $context['skip_thumbnails'] ) {
462 add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );
463 $thumbs_disabled = true;
464 }
465
466 $current_row = $start_row;
467 $max_rows = $context['batch_size'];
468
469 while ( 0 === $max_rows || $processed_batch < $max_rows ) {
470 if ( $this->should_stop_batch_before_next_row( $processed_batch, $start_time, $time_limit, $context ) ) {
471 $time_limited = true;
472 break;
473 }
474
475 $row_data = $this->read_csv_row( $handle, $delimiter );
476 if ( false === $row_data ) {
477 $reached_eof = true;
478 break;
479 }
480
481 if ( $this->is_csv_row_empty( $row_data ) ) {
482 continue;
483 }
484
485 $current_row++;
486 $row = $this->build_row_from_csv( $row_data, $header_map );
487 $result = $this->process_single_item( $row, $context['local_import'], $context['honor_relative_path'], $context );
488
489 $result['row_number'] = $current_row;
490 if ( isset( $result['file'] ) ) {
491 $result['file'] = '#' . $current_row . ' - ' . $result['file'];
492 }
493
494 $results[] = $result;
495 $summary = $this->increment_result_summary( $summary, $result );
496 $processed_batch++;
497 }
498
499 $next_row = $start_row + $processed_batch;
500 $is_finished = ( $total_rows > 0 && $next_row >= $total_rows ) || $reached_eof;
501 } catch ( Exception $exception ) {
502 if ( $thumbs_disabled ) {
503 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );
504 }
505
506 if ( is_resource( $handle ) ) {
507 $this->close_file_handle( $handle );
508 }
509
510 return new WP_Error( 'eim_import_runtime_error', $exception->getMessage() );
511 }
512
513 if ( $thumbs_disabled ) {
514 remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array' );
515 }
516
517 if ( is_resource( $handle ) ) {
518 $this->close_file_handle( $handle );
519 }
520
521 return $this->build_batch_response(
522 $results,
523 $summary,
524 [
525 'start_row' => $start_row,
526 'next_row' => $next_row,
527 'processed_rows' => $processed_batch,
528 'batch_size' => $context['batch_size'],
529 'total_rows' => $total_rows,
530 'is_finished' => $is_finished,
531 'time_limited' => $time_limited,
532 'time_limit' => $time_limit,
533 'local_import' => $context['local_import'],
534 'skip_thumbnails' => $context['skip_thumbnails'],
535 'honor_rel_path' => $context['honor_relative_path'],
536 'dry_run' => ! empty( $context['dry_run'] ),
537 'duplicate_strategy' => $context['duplicate_strategy'],
538 'pro_history_id' => isset( $context['pro_history_id'] ) ? absint( $context['pro_history_id'] ) : 0,
539 'pro_job_id' => isset( $context['pro_job_id'] ) ? absint( $context['pro_job_id'] ) : 0,
540 'convert_images_format' => isset( $context['convert_images_format'] ) ? (string) $context['convert_images_format'] : 'keep',
541 'file' => isset( $context['file'] ) ? (string) $context['file'] : wp_basename( $file_path ),
542 'source' => isset( $context['source'] ) ? (string) $context['source'] : 'programmatic',
543 ]
544 );
545 }
546
547 private function process_single_item( $row, $local_import, $honor_relative_path = true, $request_context = [] ) {
548 $row = is_array( $row ) ? $row : [];
549 $row = apply_filters(
550 'eim_import_row_data',
551 $row,
552 [
553 'local_import' => (bool) $local_import,
554 'honor_relative_path' => (bool) $honor_relative_path,
555 'request_context' => is_array( $request_context ) ? $request_context : [],
556 ]
557 );
558
559 $url = isset( $row['url'] ) ? trim( (string) $row['url'] ) : '';
560 $rel_path_raw = isset( $row['rel_path'] ) ? trim( (string) $row['rel_path'] ) : '';
561 $rel_path = $this->sanitize_relative_path( $rel_path_raw );
562 $csv_id = isset( $row['id'] ) ? absint( $row['id'] ) : 0;
563
564 $title = isset( $row['title'] ) ? sanitize_text_field( (string) $row['title'] ) : '';
565 $alt = isset( $row['alt'] ) ? sanitize_text_field( (string) $row['alt'] ) : '';
566 $caption = isset( $row['caption'] ) ? sanitize_text_field( (string) $row['caption'] ) : '';
567 $description = isset( $row['description'] ) ? wp_kses_post( (string) $row['description'] ) : '';
568 $custom_meta = $this->decode_custom_meta_json( isset( $row['custom_meta_json'] ) ? $row['custom_meta_json'] : '' );
569
570 $filename = isset( $row['file'] ) ? (string) $row['file'] : '';
571 $filename = $filename ? $filename : $this->derive_filename( $url, $rel_path );
572 $filename = $this->normalize_import_filename( $filename, $url, $rel_path );
573 $url = apply_filters( 'eim_pre_import_url', $url, $row );
574 $request_context = is_array( $request_context ) ? $request_context : [];
575 $advanced_actions_allowed = ! empty( $request_context['advanced_import_actions_allowed'] );
576 $dry_run = ! empty( $request_context['dry_run'] );
577 $duplicate_strategy = $this->normalize_duplicate_strategy(
578 isset( $request_context['duplicate_strategy'] ) ? $request_context['duplicate_strategy'] : 'skip',
579 $advanced_actions_allowed
580 );
581 $match_strategy = $this->normalize_match_strategy(
582 isset( $request_context['match_strategy'] ) ? $request_context['match_strategy'] : 'auto',
583 $advanced_actions_allowed
584 );
585 $selected_update_fields = $advanced_actions_allowed
586 ? $this->normalize_selected_update_fields(
587 isset( $request_context['selected_update_fields'] ) ? $request_context['selected_update_fields'] : []
588 )
589 : [];
590 $allows_match_without_source = $this->can_attempt_match_without_source( $csv_id, $filename, $match_strategy );
591 $context = [
592 'local_import' => (bool) $local_import,
593 'honor_relative_path' => (bool) $honor_relative_path,
594 'csv_id' => $csv_id,
595 'url' => $url,
596 'relative_path' => $rel_path,
597 'filename' => $filename,
598 'dry_run' => $dry_run,
599 'duplicate_strategy' => $duplicate_strategy,
600 'match_strategy' => $match_strategy,
601 'selected_update_fields' => $selected_update_fields,
602 'advanced_import_actions_allowed' => $advanced_actions_allowed,
603 'custom_meta' => $custom_meta,
604 'request_context' => $request_context,
605 ];
606
607 $this->log_import_event(
608 'row_start',
609 [
610 'filename' => $filename,
611 'csv_id' => $csv_id,
612 'relative_path' => $rel_path,
613 'url' => $url,
614 'local_import' => (bool) $local_import,
615 'honor_relative_path' => (bool) $honor_relative_path,
616 'dry_run' => $dry_run,
617 ]
618 );
619
620 $validation = $this->validate_row_via_hooks( $row, $context );
621 if ( is_wp_error( $validation ) ) {
622 $this->log_import_event(
623 'row_validation_error',
624 [
625 'filename' => $filename,
626 'message' => $validation->get_error_message(),
627 ]
628 );
629
630 return $this->build_item_result(
631 'ERROR',
632 $filename,
633 $validation->get_error_message(),
634 [ 'reason' => 'custom_validation_failed' ]
635 );
636 }
637
638 do_action( 'eim_before_import_media', $row, $context );
639
640 if ( '' === $url && '' === $rel_path && ! $allows_match_without_source ) {
641 $this->log_import_event(
642 'row_missing_source',
643 [
644 'filename' => $filename,
645 'csv_id' => $csv_id,
646 ]
647 );
648
649 return $this->build_item_result(
650 'ERROR',
651 $filename,
652 __( 'Row is missing Absolute URL and Relative Path, and it does not provide a usable Attachment ID or Filename match.', 'calliope-media-import-export' ),
653 [ 'reason' => 'missing_source' ]
654 );
655 }
656
657 require_once ABSPATH . 'wp-admin/includes/image.php';
658 require_once ABSPATH . 'wp-admin/includes/file.php';
659 require_once ABSPATH . 'wp-admin/includes/media.php';
660
661 $existing_id = 0;
662 $deferred_duplicate_id = 0;
663 $deferred_duplicate_reason = '';
664
665 if ( 'filename' === $match_strategy && '' !== $filename ) {
666 $filename_candidates = array_values(
667 array_unique(
668 array_filter(
669 array_map( 'absint', $this->find_attachments_by_name_candidates( $filename, $rel_path ) )
670 )
671 )
672 );
673
674 if ( count( $filename_candidates ) > 1 ) {
675 return $this->build_item_result(
676 'ERROR',
677 $filename,
678 __( 'Filename matching is ambiguous because multiple existing media items share this filename. Add Relative Path or Attachment ID to target a single attachment.', 'calliope-media-import-export' ),
679 [
680 'reason' => 'ambiguous_filename_match',
681 ]
682 );
683 }
684
685 if ( 1 === count( $filename_candidates ) ) {
686 $existing_id = (int) $filename_candidates[0];
687 }
688 }
689
690 if ( ! $existing_id ) {
691 $existing_id = $this->find_existing_attachment_id( $url, $rel_path, null, $filename, '', $match_strategy );
692 }
693
694 if ( $existing_id ) {
695 if ( 'replace_file' === $duplicate_strategy && ! $dry_run ) {
696 $deferred_duplicate_id = $existing_id;
697 $deferred_duplicate_reason = 'duplicate_existing';
698 } else {
699 $duplicate_result = $this->resolve_duplicate_result(
700 $existing_id,
701 $duplicate_strategy,
702 $dry_run,
703 $filename,
704 $title,
705 $alt,
706 $caption,
707 $description,
708 $url,
709 $rel_path,
710 '',
711 '',
712 $custom_meta,
713 $row,
714 'duplicate_existing',
715 $request_context
716 );
717 if ( null !== $duplicate_result ) {
718 return $duplicate_result;
719 }
720 }
721 }
722
723 $id_match = $this->maybe_match_existing_attachment_by_csv_id( $csv_id, $url, $rel_path, $match_strategy );
724 if ( $id_match ) {
725 if ( 'replace_file' === $duplicate_strategy && ! $dry_run ) {
726 $deferred_duplicate_id = $id_match;
727 $deferred_duplicate_reason = 'csv_id_match';
728 } else {
729 $duplicate_result = $this->resolve_duplicate_result(
730 $id_match,
731 $duplicate_strategy,
732 $dry_run,
733 $filename,
734 $title,
735 $alt,
736 $caption,
737 $description,
738 $url,
739 $rel_path,
740 '',
741 '',
742 $custom_meta,
743 $row,
744 'csv_id_match',
745 $request_context
746 );
747 if ( null !== $duplicate_result ) {
748 return $duplicate_result;
749 }
750 }
751 }
752
753 if ( '' === $url && '' === $rel_path ) {
754 $message = in_array( $duplicate_strategy, [ 'replace_file', 'force_new' ], true )
755 ? __( 'This import action requires Absolute URL or Relative Path when no existing media match is found.', 'calliope-media-import-export' )
756 : __( 'No existing media matched the selected criteria, and the row does not include source data for a new import.', 'calliope-media-import-export' );
757
758 return $this->build_item_result(
759 'ERROR',
760 $filename,
761 $message,
762 [ 'reason' => 'missing_source_after_match' ]
763 );
764 }
765
766 if ( $local_import ) {
767 $source_file = $this->resolve_uploads_file_from_source( $url, $rel_path );
768
769 if ( '' === $rel_path && '' === $source_file ) {
770 $this->log_import_event(
771 'local_import_missing_relative_path',
772 [
773 'filename' => $filename,
774 'url' => $url,
775 ]
776 );
777
778 return $this->build_item_result(
779 'ERROR',
780 $filename,
781 __( 'Local Import Mode requires a valid "Relative Path" value.', 'calliope-media-import-export' ),
782 [ 'reason' => 'missing_relative_path' ]
783 );
784 }
785
786 if ( '' === $source_file ) {
787 $this->log_import_event(
788 'local_import_file_missing',
789 [
790 'filename' => $filename,
791 'relative_path'=> $rel_path,
792 'url' => $url,
793 'checked_path' => $this->build_uploads_candidate_path( $rel_path ),
794 ]
795 );
796
797 return $this->build_item_result(
798 'ERROR',
799 $filename,
800 __( 'Local file not found in uploads. Copy the media files into wp-content/uploads or use a reachable Absolute URL.', 'calliope-media-import-export' ),
801 [ 'reason' => 'local_file_missing' ]
802 );
803 }
804
805 $this->log_import_event(
806 'local_import_file_found',
807 [
808 'filename' => $filename,
809 'relative_path'=> $rel_path,
810 'source_file' => $source_file,
811 ]
812 );
813
814 if ( $dry_run ) {
815 $validated = $this->validate_existing_media_file( $source_file );
816 if ( is_wp_error( $validated ) ) {
817 $this->log_import_event(
818 'local_import_file_invalid_dry_run',
819 [
820 'filename' => $filename,
821 'message' => $validated->get_error_message(),
822 ]
823 );
824
825 return $this->build_item_result(
826 'ERROR',
827 $filename,
828 $validated->get_error_message(),
829 [ 'reason' => 'local_file_invalid_dry_run' ]
830 );
831 }
832
833 return $this->build_item_result(
834 'READY',
835 $filename,
836 __( 'Dry run: local file is ready to import.', 'calliope-media-import-export' ),
837 [
838 'reason' => 'dry_run_ready_local',
839 'import_method' => 'local',
840 ]
841 );
842 }
843
844 return $this->attach_existing_media_file(
845 $source_file,
846 $filename,
847 $title,
848 $alt,
849 $caption,
850 $description,
851 $url,
852 $rel_path,
853 $row,
854 $request_context
855 );
856 }
857
858 if ( '' === $url ) {
859 return $this->build_item_result(
860 'ERROR',
861 $filename,
862 __( 'Absolute URL is missing. Provide a URL or enable Local Import Mode for Relative Path imports.', 'calliope-media-import-export' ),
863 [ 'reason' => 'missing_url' ]
864 );
865 }
866
867 if ( ! wp_http_validate_url( $url ) ) {
868 return $this->build_item_result(
869 'ERROR',
870 $filename,
871 __( 'The "Absolute URL" value is not valid.', 'calliope-media-import-export' ),
872 [ 'reason' => 'invalid_url' ]
873 );
874 }
875
876 $existing_file = $this->resolve_uploads_file_from_source( $url, $honor_relative_path ? $rel_path : '' );
877
878 if ( '' !== $existing_file ) {
879 $this->log_import_event(
880 'existing_upload_file_found',
881 [
882 'filename' => $filename,
883 'relative_path'=> $rel_path,
884 'url' => $url,
885 'source_file' => $existing_file,
886 ]
887 );
888
889 if ( $dry_run ) {
890 return $this->build_item_result(
891 'READY',
892 $filename,
893 __( 'Dry run: media would reuse the existing file from uploads.', 'calliope-media-import-export' ),
894 [
895 'reason' => 'dry_run_ready_existing_upload',
896 'import_method' => 'local',
897 ]
898 );
899 }
900
901 return $this->attach_existing_media_file(
902 $existing_file,
903 $filename,
904 $title,
905 $alt,
906 $caption,
907 $description,
908 $url,
909 $rel_path,
910 $row,
911 $request_context
912 );
913 }
914
915 if ( $this->looks_like_local_upload_url( $url ) ) {
916 $this->log_import_event(
917 'local_upload_url_missing_file',
918 [
919 'filename' => $filename,
920 'relative_path'=> $rel_path,
921 'url' => $url,
922 'checked_path' => $this->build_uploads_candidate_path( $rel_path ),
923 ]
924 );
925
926 return $this->build_item_result(
927 'ERROR',
928 $filename,
929 __( 'The URL points to this local uploads folder, but the file is missing on disk.', 'calliope-media-import-export' ),
930 [ 'reason' => 'local_upload_url_file_missing' ]
931 );
932 }
933
934 if ( $dry_run ) {
935 return $this->build_item_result(
936 'READY',
937 $filename,
938 __( 'Dry run: media appears ready to import.', 'calliope-media-import-export' ),
939 [
940 'reason' => 'dry_run_ready_remote',
941 'import_method' => 'remote',
942 ]
943 );
944 }
945
946 $download_timeout = $this->get_download_timeout( $request_context, $url );
947 $download_start = microtime( true );
948
949 $this->log_import_event(
950 'download_start',
951 [
952 'filename' => $filename,
953 'url' => $url,
954 'timeout' => $download_timeout,
955 ]
956 );
957
958 $tmp_file = download_url( $url, $download_timeout );
959 if ( is_wp_error( $tmp_file ) ) {
960 $this->log_import_event(
961 'download_error',
962 [
963 'filename' => $filename,
964 'url' => $url,
965 'timeout' => $download_timeout,
966 'elapsed' => round( microtime( true ) - $download_start, 3 ),
967 'message' => $tmp_file->get_error_message(),
968 ]
969 );
970
971 return $this->build_item_result(
972 'ERROR',
973 $filename,
974 /* translators: %s: WordPress error message returned while downloading a remote file. */
975 sprintf( __( 'Download error: %s', 'calliope-media-import-export' ), $tmp_file->get_error_message() ),
976 [ 'reason' => 'download_error' ]
977 );
978 }
979
980 $this->log_import_event(
981 'download_success',
982 [
983 'filename' => $filename,
984 'url' => $url,
985 'timeout' => $download_timeout,
986 'elapsed' => round( microtime( true ) - $download_start, 3 ),
987 'tmp_file' => $tmp_file,
988 ]
989 );
990
991 $filename = apply_filters( 'eim_import_filename', $filename, $row );
992 $filename = $this->normalize_import_filename( $filename, $url, $rel_path );
993 $file_array = [
994 'name' => $filename ? $filename : 'media-file',
995 'tmp_name' => $tmp_file,
996 ];
997
998 $svg_validation = $this->maybe_validate_svg_import_file( $tmp_file, $file_array['name'] );
999 if ( is_wp_error( $svg_validation ) ) {
1000 if ( file_exists( $tmp_file ) ) {
1001 wp_delete_file( $tmp_file );
1002 }
1003
1004 return $this->build_item_result(
1005 'ERROR',
1006 $filename,
1007 $svg_validation->get_error_message(),
1008 [ 'reason' => 'svg_validation_failed' ]
1009 );
1010 }
1011
1012 $fingerprint = $this->get_file_fingerprint( $tmp_file );
1013
1014 if ( $deferred_duplicate_id ) {
1015 $duplicate_result = $this->resolve_duplicate_result(
1016 $deferred_duplicate_id,
1017 $duplicate_strategy,
1018 $dry_run,
1019 $filename,
1020 $title,
1021 $alt,
1022 $caption,
1023 $description,
1024 $url,
1025 $rel_path,
1026 $fingerprint,
1027 $tmp_file,
1028 $custom_meta,
1029 $row,
1030 $deferred_duplicate_reason ? $deferred_duplicate_reason : 'duplicate_existing',
1031 $request_context
1032 );
1033
1034 if ( null !== $duplicate_result ) {
1035 if ( file_exists( $tmp_file ) ) {
1036 wp_delete_file( $tmp_file );
1037 }
1038
1039 return $duplicate_result;
1040 }
1041 }
1042
1043 $existing_id = $this->find_existing_attachment_id( $url, $rel_path, $tmp_file, $filename, $fingerprint, $match_strategy );
1044
1045 if ( $existing_id ) {
1046 $duplicate_result = $this->resolve_duplicate_result(
1047 $existing_id,
1048 $duplicate_strategy,
1049 $dry_run,
1050 $filename,
1051 $title,
1052 $alt,
1053 $caption,
1054 $description,
1055 $url,
1056 $rel_path,
1057 $fingerprint,
1058 $tmp_file,
1059 $custom_meta,
1060 $row,
1061 'duplicate_existing',
1062 $request_context
1063 );
1064 if ( null !== $duplicate_result ) {
1065 if ( file_exists( $tmp_file ) ) {
1066 wp_delete_file( $tmp_file );
1067 }
1068
1069 return $duplicate_result;
1070 }
1071 }
1072
1073 $subdir = '';
1074 if ( $honor_relative_path && '' !== $rel_path ) {
1075 $dir = dirname( $rel_path );
1076 if ( $dir && '.' !== $dir ) {
1077 $subdir = '/' . trim( $dir, '/' );
1078 }
1079 }
1080
1081 $id = $this->media_handle_sideload_with_subdir( $file_array, $subdir );
1082 if ( is_wp_error( $id ) ) {
1083 if ( file_exists( $tmp_file ) ) {
1084 wp_delete_file( $tmp_file );
1085 }
1086
1087 return $this->build_item_result(
1088 'ERROR',
1089 $filename,
1090 $id->get_error_message(),
1091 [ 'reason' => 'media_handle_error' ]
1092 );
1093 }
1094
1095 wp_update_post(
1096 [
1097 'ID' => $id,
1098 'post_title' => $title ? $title : $filename,
1099 'post_excerpt' => $caption,
1100 'post_content' => $description,
1101 ]
1102 );
1103
1104 $mime = get_post_mime_type( $id );
1105 if ( $alt && $mime && 0 === strpos( $mime, 'image/' ) ) {
1106 update_post_meta( $id, '_wp_attachment_image_alt', $alt );
1107 }
1108
1109 $this->apply_custom_meta( $id, $custom_meta );
1110 $this->store_source_meta( $id, $url, $rel_path );
1111 if ( $fingerprint ) {
1112 $this->store_fingerprint_meta( $id, $fingerprint );
1113 }
1114
1115 do_action( 'eim_after_import_image', $id, $row );
1116 do_action( 'eim_after_import_media', $id, $row );
1117 do_action( 'eim_after_import_media_with_context', $id, $row, $this->build_import_action_context( $request_context, $row, [ 'attachment_id' => (int) $id, 'action' => 'new_attachment' ] ) );
1118
1119 return $this->build_item_result(
1120 'IMPORTED',
1121 $filename,
1122 /* translators: %d: attachment ID. */
1123 sprintf( __( 'Imported successfully (ID %d)', 'calliope-media-import-export' ), (int) $id ),
1124 [
1125 'reason' => 'imported',
1126 'attachment_id' => (int) $id,
1127 'import_method' => 'remote',
1128 'request_context' => $this->get_result_request_context( $request_context ),
1129 ]
1130 );
1131 }
1132
1133 private function attach_existing_media_file( $file_path, $filename, $title, $alt, $caption, $description, $url, $rel_path, $row, $request_context = [] ) {
1134 if ( ! file_exists( $file_path ) ) {
1135 $this->log_import_event(
1136 'attach_local_missing',
1137 [
1138 'filename' => $filename,
1139 'file_path' => $file_path,
1140 'url' => $url,
1141 'rel_path' => $rel_path,
1142 ]
1143 );
1144
1145 return $this->build_item_result( 'ERROR', $filename, __( 'Local file not found.', 'calliope-media-import-export' ) );
1146 }
1147
1148 if ( ! $this->is_path_inside_uploads( $file_path ) ) {
1149 $this->log_import_event(
1150 'attach_local_invalid_path',
1151 [
1152 'filename' => $filename,
1153 'file_path' => $file_path,
1154 ]
1155 );
1156
1157 return $this->build_item_result( 'ERROR', $filename, __( 'Invalid local path.', 'calliope-media-import-export' ) );
1158 }
1159
1160 $this->log_import_event(
1161 'attach_local_start',
1162 [
1163 'filename' => $filename,
1164 'file_path' => $file_path,
1165 'bytes' => filesize( $file_path ),
1166 'url' => $url,
1167 'rel_path' => $rel_path,
1168 ]
1169 );
1170
1171 $validated = $this->validate_existing_media_file( $file_path );
1172 if ( is_wp_error( $validated ) ) {
1173 $this->log_import_event(
1174 'attach_local_invalid_file',
1175 [
1176 'filename' => $filename,
1177 'message' => $validated->get_error_message(),
1178 ]
1179 );
1180
1181 return $this->build_item_result( 'ERROR', $filename, $validated->get_error_message() );
1182 }
1183
1184 $final_filename = $filename ? $filename : $validated['filename'];
1185 $final_filename = $this->normalize_import_filename( $final_filename, $url, $rel_path );
1186 $fingerprint = $this->get_file_fingerprint( $file_path );
1187 $custom_meta = $this->decode_custom_meta_json( isset( $row['custom_meta_json'] ) ? $row['custom_meta_json'] : '' );
1188 $request_context = is_array( $request_context ) ? $request_context : [];
1189 $advanced_actions_allowed = ! empty( $request_context['advanced_import_actions_allowed'] );
1190 $existing_id = $this->find_existing_attachment_id(
1191 $url,
1192 $rel_path,
1193 $file_path,
1194 $final_filename,
1195 $fingerprint,
1196 $this->normalize_match_strategy(
1197 isset( $request_context['match_strategy'] ) ? $request_context['match_strategy'] : 'auto',
1198 $advanced_actions_allowed
1199 )
1200 );
1201 $duplicate_strategy = $this->normalize_duplicate_strategy(
1202 isset( $request_context['duplicate_strategy'] ) ? $request_context['duplicate_strategy'] : 'skip',
1203 $advanced_actions_allowed
1204 );
1205
1206 if ( $existing_id ) {
1207 $this->log_import_event(
1208 'attach_local_duplicate',
1209 [
1210 'filename' => $final_filename,
1211 'existing_id' => (int) $existing_id,
1212 'strategy' => $duplicate_strategy,
1213 ]
1214 );
1215
1216 $duplicate_result = $this->resolve_duplicate_result(
1217 $existing_id,
1218 $duplicate_strategy,
1219 ! empty( $request_context['dry_run'] ),
1220 $final_filename,
1221 $title,
1222 $alt,
1223 $caption,
1224 $description,
1225 $url,
1226 $rel_path,
1227 $fingerprint,
1228 $file_path,
1229 $custom_meta,
1230 $row,
1231 'duplicate_existing',
1232 $request_context
1233 );
1234 if ( null !== $duplicate_result ) {
1235 return $duplicate_result;
1236 }
1237 }
1238
1239 $attachment = [
1240 'post_mime_type' => $validated['mime'],
1241 'post_title' => $title ? $title : ( $final_filename ? $final_filename : __( 'Media', 'calliope-media-import-export' ) ),
1242 'post_content' => $description,
1243 'post_excerpt' => $caption,
1244 'post_status' => 'inherit',
1245 ];
1246
1247 $id = wp_insert_attachment( $attachment, $file_path, 0 );
1248 if ( is_wp_error( $id ) ) {
1249 $this->log_import_event(
1250 'attach_local_insert_error',
1251 [
1252 'filename' => $final_filename,
1253 'message' => $id->get_error_message(),
1254 ]
1255 );
1256
1257 return $this->build_item_result(
1258 'ERROR',
1259 $final_filename,
1260 $id->get_error_message(),
1261 [ 'reason' => 'wp_insert_attachment_error' ]
1262 );
1263 }
1264
1265 update_attached_file( $id, $file_path );
1266
1267 $metadata_start = microtime( true );
1268 if ( 'image/svg+xml' === $validated['mime'] ) {
1269 wp_update_attachment_metadata( $id, [] );
1270 } else {
1271 $attach_data = wp_generate_attachment_metadata( $id, $file_path );
1272 if ( ! empty( $attach_data ) && ! is_wp_error( $attach_data ) ) {
1273 wp_update_attachment_metadata( $id, $attach_data );
1274 }
1275 }
1276 $this->log_import_event(
1277 'attach_local_metadata_done',
1278 [
1279 'filename' => $final_filename,
1280 'attachment_id' => (int) $id,
1281 'elapsed' => round( microtime( true ) - $metadata_start, 3 ),
1282 'mime' => $validated['mime'],
1283 ]
1284 );
1285
1286 if ( $alt && 0 === strpos( $validated['mime'], 'image/' ) ) {
1287 update_post_meta( $id, '_wp_attachment_image_alt', $alt );
1288 }
1289
1290 $this->apply_custom_meta( $id, $custom_meta );
1291 $this->store_source_meta( $id, $url, $rel_path );
1292 if ( $fingerprint ) {
1293 $this->store_fingerprint_meta( $id, $fingerprint );
1294 }
1295
1296 do_action( 'eim_after_import_image', $id, $row );
1297 do_action( 'eim_after_import_media', $id, $row );
1298 do_action( 'eim_after_import_media_with_context', $id, $row, $this->build_import_action_context( $request_context, $row, [ 'attachment_id' => (int) $id, 'action' => 'new_attachment' ] ) );
1299
1300 $this->log_import_event(
1301 'attach_local_imported',
1302 [
1303 'filename' => $final_filename,
1304 'attachment_id' => (int) $id,
1305 ]
1306 );
1307
1308 return $this->build_item_result(
1309 'IMPORTED',
1310 $final_filename,
1311 /* translators: %d: attachment ID. */
1312 sprintf( __( 'Imported successfully (ID %d)', 'calliope-media-import-export' ), (int) $id ),
1313 [
1314 'reason' => 'imported',
1315 'attachment_id' => (int) $id,
1316 'import_method' => 'local',
1317 'request_context' => $this->get_result_request_context( $request_context ),
1318 ]
1319 );
1320 }
1321
1322 private function find_existing_attachment_id( $url, $rel_path, $incoming_file_path = null, $filename = '', $incoming_fingerprint = '', $match_strategy = 'auto' ) {
1323 global $wpdb;
1324
1325 $url = is_string( $url ) ? trim( $url ) : '';
1326 $rel_path = is_string( $rel_path ) ? trim( $rel_path ) : '';
1327 $filename = is_string( $filename ) ? trim( $filename ) : '';
1328 $match_strategy = $this->normalize_match_strategy( $match_strategy );
1329
1330 if ( 'attachment_id' === $match_strategy ) {
1331 return 0;
1332 }
1333
1334 if ( in_array( $match_strategy, [ 'auto', 'source_url' ], true ) && '' !== $url ) {
1335 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for existing imported media.
1336 $id = (int) $wpdb->get_var(
1337 $wpdb->prepare(
1338 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1339 '_eim_source_url',
1340 $url
1341 )
1342 );
1343 if ( $id ) {
1344 return $id;
1345 }
1346 }
1347
1348 if ( in_array( $match_strategy, [ 'auto', 'relative_path' ], true ) && '' !== $rel_path ) {
1349 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for existing imported media.
1350 $id = (int) $wpdb->get_var(
1351 $wpdb->prepare(
1352 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1353 '_eim_source_rel_path',
1354 $rel_path
1355 )
1356 );
1357 if ( $id ) {
1358 return $id;
1359 }
1360
1361 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for existing attachments by relative path.
1362 $id = (int) $wpdb->get_var(
1363 $wpdb->prepare(
1364 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1365 '_wp_attached_file',
1366 $rel_path
1367 )
1368 );
1369 if ( $id ) {
1370 return $id;
1371 }
1372 }
1373
1374 if ( in_array( $match_strategy, [ 'auto', 'source_url' ], true ) && '' !== $url ) {
1375 $local_id = (int) attachment_url_to_postid( $url );
1376 if ( $local_id ) {
1377 return $local_id;
1378 }
1379 }
1380
1381 $has_incoming_file = ! empty( $incoming_file_path ) && is_string( $incoming_file_path ) && file_exists( $incoming_file_path );
1382 $fingerprint = '';
1383
1384 if ( $has_incoming_file ) {
1385 $fingerprint = $incoming_fingerprint ? (string) $incoming_fingerprint : $this->get_file_fingerprint( $incoming_file_path );
1386 if ( $fingerprint ) {
1387 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for fingerprint matching.
1388 $id = (int) $wpdb->get_var(
1389 $wpdb->prepare(
1390 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1391 '_eim_file_fingerprint',
1392 $fingerprint
1393 )
1394 );
1395 if ( $id ) {
1396 return $id;
1397 }
1398
1399 if ( 0 === strpos( $fingerprint, 'md5:' ) ) {
1400 $md5 = substr( $fingerprint, 4 );
1401 if ( $md5 ) {
1402 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for md5 matching.
1403 $id = (int) $wpdb->get_var(
1404 $wpdb->prepare(
1405 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
1406 '_eim_file_hash',
1407 $md5
1408 )
1409 );
1410 if ( $id ) {
1411 return $id;
1412 }
1413 }
1414 }
1415 }
1416 }
1417
1418 if ( in_array( $match_strategy, [ 'auto', 'filename' ], true ) && $filename ) {
1419 $candidates = $this->find_attachments_by_name_candidates( $filename, $rel_path );
1420
1421 foreach ( $candidates as $candidate_id ) {
1422 $candidate_id = absint( $candidate_id );
1423 if ( ! $candidate_id ) {
1424 continue;
1425 }
1426
1427 if ( 'filename' === $match_strategy ) {
1428 return $candidate_id;
1429 }
1430
1431 if ( ! $has_incoming_file || ! $fingerprint ) {
1432 continue;
1433 }
1434
1435 $candidate_fp = (string) get_post_meta( $candidate_id, '_eim_file_fingerprint', true );
1436 if ( ! $candidate_fp ) {
1437 $candidate_file = get_attached_file( $candidate_id );
1438 if ( ! $candidate_file || ! file_exists( $candidate_file ) ) {
1439 continue;
1440 }
1441
1442 $candidate_fp = $this->get_file_fingerprint( $candidate_file );
1443 if ( $candidate_fp ) {
1444 update_post_meta( $candidate_id, '_eim_file_fingerprint', $candidate_fp );
1445
1446 if ( 0 === strpos( $candidate_fp, 'md5:' ) ) {
1447 update_post_meta( $candidate_id, '_eim_file_hash', substr( $candidate_fp, 4 ) );
1448 }
1449 }
1450 }
1451
1452 if ( $candidate_fp && hash_equals( $fingerprint, $candidate_fp ) ) {
1453 return $candidate_id;
1454 }
1455 }
1456 }
1457
1458 return 0;
1459 }
1460
1461 private function find_attachments_by_name_candidates( $filename, $rel_path = '' ) {
1462 global $wpdb;
1463
1464 $filename = trim( (string) $filename );
1465 if ( '' === $filename ) {
1466 return [];
1467 }
1468
1469 $pathinfo = pathinfo( $filename );
1470 $base = isset( $pathinfo['filename'] ) ? $pathinfo['filename'] : $filename;
1471 $ext = isset( $pathinfo['extension'] ) && '' !== $pathinfo['extension'] ? '.' . $pathinfo['extension'] : '';
1472 $dir = '';
1473
1474 if ( $rel_path ) {
1475 $dir = dirname( (string) $rel_path );
1476 if ( $dir && '.' !== $dir ) {
1477 $dir = trim( $dir, '/' );
1478 } else {
1479 $dir = '';
1480 }
1481 }
1482
1483 if ( '' !== $dir ) {
1484 $exact = $dir . '/' . $filename;
1485 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for filename candidates.
1486 $ids = $wpdb->get_col(
1487 $wpdb->prepare(
1488 "SELECT post_id FROM {$wpdb->postmeta}
1489 WHERE meta_key = %s AND (meta_value = %s OR meta_value LIKE %s OR meta_value LIKE %s)
1490 LIMIT 50",
1491 '_wp_attached_file',
1492 $exact,
1493 $dir . '/' . $base . '-%' . $ext,
1494 $dir . '/' . $base . '%' . $ext
1495 )
1496 );
1497
1498 return array_map( 'absint', (array) $ids );
1499 }
1500
1501 $like_exact = '%' . $wpdb->esc_like( '/' . $filename );
1502 $like_variants = '%' . $wpdb->esc_like( '/' . $base . '-' ) . '%' . $wpdb->esc_like( $ext );
1503 $like_scaled = '%' . $wpdb->esc_like( '/' . $base ) . '%' . $wpdb->esc_like( $ext );
1504
1505 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared postmeta lookup for filename candidates.
1506 $ids = $wpdb->get_col(
1507 $wpdb->prepare(
1508 "SELECT post_id FROM {$wpdb->postmeta}
1509 WHERE meta_key = %s AND (meta_value LIKE %s OR meta_value LIKE %s OR meta_value LIKE %s)
1510 LIMIT 50",
1511 '_wp_attached_file',
1512 $like_exact,
1513 $like_variants,
1514 $like_scaled
1515 )
1516 );
1517
1518 return array_map( 'absint', (array) $ids );
1519 }
1520
1521 private function maybe_match_existing_attachment_by_csv_id( $csv_id, $url, $rel_path, $match_strategy = 'auto' ) {
1522 $csv_id = absint( $csv_id );
1523 if ( ! $csv_id || 'attachment' !== get_post_type( $csv_id ) ) {
1524 return 0;
1525 }
1526
1527 $match_strategy = $this->normalize_match_strategy( $match_strategy );
1528 if ( ! in_array( $match_strategy, [ 'auto', 'attachment_id' ], true ) ) {
1529 return 0;
1530 }
1531
1532 if ( 'attachment_id' === $match_strategy ) {
1533 return $csv_id;
1534 }
1535
1536 $allow_match = 'attachment_id' === $match_strategy
1537 ? true
1538 : (bool) apply_filters( 'eim_allow_csv_id_match', false, $csv_id, $url, $rel_path );
1539 if ( ! $allow_match ) {
1540 return 0;
1541 }
1542
1543 $attached_file = (string) get_post_meta( $csv_id, '_wp_attached_file', true );
1544 $stored_url = (string) get_post_meta( $csv_id, '_eim_source_url', true );
1545 $stored_rel = (string) get_post_meta( $csv_id, '_eim_source_rel_path', true );
1546
1547 if ( $rel_path && ( $attached_file === $rel_path || $stored_rel === $rel_path ) ) {
1548 return $csv_id;
1549 }
1550
1551 if ( $url && $stored_url === $url ) {
1552 return $csv_id;
1553 }
1554
1555 return 0;
1556 }
1557
1558 private function resolve_duplicate_result( $attachment_id, $strategy, $dry_run, $filename, $title, $alt, $caption, $description, $url, $rel_path, $fingerprint, $incoming_file_path, $custom_meta, $row, $reason, $request_context = [] ) {
1559 $attachment_id = absint( $attachment_id );
1560 if ( ! $attachment_id ) {
1561 return null;
1562 }
1563
1564 $request_context = is_array( $request_context ) ? $request_context : [];
1565 $advanced_actions_allowed = ! empty( $request_context['advanced_import_actions_allowed'] );
1566 $selected_update_fields = $advanced_actions_allowed
1567 ? $this->normalize_selected_update_fields(
1568 isset( $request_context['selected_update_fields'] ) ? $request_context['selected_update_fields'] : []
1569 )
1570 : [];
1571
1572 $strategy = apply_filters(
1573 'eim_duplicate_handling_strategy',
1574 $this->normalize_duplicate_strategy( $strategy, $advanced_actions_allowed ),
1575 $attachment_id,
1576 $row,
1577 [
1578 'reason' => (string) $reason,
1579 'dry_run' => (bool) $dry_run,
1580 'filename' => (string) $filename,
1581 'url' => (string) $url,
1582 'relative_path' => (string) $rel_path,
1583 'advanced_import_actions_allowed' => $advanced_actions_allowed,
1584 ]
1585 );
1586 $strategy = $this->normalize_duplicate_strategy( $strategy, $advanced_actions_allowed );
1587
1588 if ( 'force_new' === $strategy ) {
1589 return null;
1590 }
1591
1592 if ( in_array( $strategy, [ 'update_metadata', 'update_selected_fields' ], true ) ) {
1593 $update_reason = 'update_selected_fields' === $strategy ? 'updated_selected_fields_only' : 'updated_metadata_only';
1594 $dry_reason = 'update_selected_fields' === $strategy ? 'dry_run_update_selected_fields' : 'dry_run_update_metadata';
1595 if ( $dry_run ) {
1596 /* translators: %d: attachment ID. */
1597 $dry_run_message = 'update_selected_fields' === $strategy
1598 ? __( 'Dry run: existing media (ID %d) would have its selected fields updated.', 'calliope-media-import-export' )
1599 : __( 'Dry run: existing media (ID %d) would have its metadata updated.', 'calliope-media-import-export' );
1600
1601 return $this->build_item_result(
1602 'READY',
1603 $filename,
1604 sprintf(
1605 $dry_run_message,
1606 $attachment_id
1607 ),
1608 [
1609 'reason' => $dry_reason,
1610 'attachment_id' => $attachment_id,
1611 'duplicate_detected' => true,
1612 ]
1613 );
1614 }
1615
1616 $this->update_existing_attachment_metadata(
1617 $attachment_id,
1618 $filename,
1619 $title,
1620 $alt,
1621 $caption,
1622 $description,
1623 $url,
1624 $rel_path,
1625 $fingerprint,
1626 $custom_meta,
1627 $row,
1628 'update_selected_fields' === $strategy ? $selected_update_fields : [],
1629 $request_context
1630 );
1631
1632 /* translators: %d: attachment ID. */
1633 $updated_message = 'update_selected_fields' === $strategy
1634 ? __( 'Updated selected fields for existing media (ID %d)', 'calliope-media-import-export' )
1635 : __( 'Updated metadata for existing media (ID %d)', 'calliope-media-import-export' );
1636
1637 return $this->build_item_result(
1638 'IMPORTED',
1639 $filename,
1640 sprintf(
1641 $updated_message,
1642 $attachment_id
1643 ),
1644 [
1645 'reason' => $update_reason,
1646 'attachment_id' => $attachment_id,
1647 'duplicate_detected' => true,
1648 'import_method' => 'update_selected_fields' === $strategy ? 'selected-fields-update' : 'metadata-update',
1649 'request_context' => $this->get_result_request_context( $request_context ),
1650 ]
1651 );
1652 }
1653
1654 if ( 'replace_file' === $strategy ) {
1655 if ( $dry_run ) {
1656 return $this->build_item_result(
1657 'READY',
1658 $filename,
1659 /* translators: %d: attachment ID. */
1660 /* translators: %d: existing attachment ID. */
1661 sprintf( __( 'Dry run: existing media (ID %d) would have its file replaced.', 'calliope-media-import-export' ), $attachment_id ),
1662 [
1663 'reason' => 'dry_run_replace_file',
1664 'attachment_id' => $attachment_id,
1665 'duplicate_detected' => true,
1666 ]
1667 );
1668 }
1669
1670 $replaced = $this->replace_existing_attachment_file(
1671 $attachment_id,
1672 $incoming_file_path,
1673 $filename,
1674 $title,
1675 $alt,
1676 $caption,
1677 $description,
1678 $url,
1679 $rel_path,
1680 $fingerprint,
1681 $custom_meta,
1682 $row,
1683 $request_context
1684 );
1685
1686 if ( is_wp_error( $replaced ) ) {
1687 return $this->build_item_result(
1688 'ERROR',
1689 $filename,
1690 $replaced->get_error_message(),
1691 [
1692 'reason' => 'replace_file_failed',
1693 'attachment_id' => $attachment_id,
1694 'duplicate_detected' => true,
1695 ]
1696 );
1697 }
1698
1699 return $this->build_item_result(
1700 'IMPORTED',
1701 $filename,
1702 /* translators: %d: attachment ID. */
1703 sprintf( __( 'Replaced the file for existing media (ID %d)', 'calliope-media-import-export' ), $attachment_id ),
1704 [
1705 'reason' => 'replaced_existing_file',
1706 'attachment_id' => $attachment_id,
1707 'duplicate_detected' => true,
1708 'import_method' => 'replace-file',
1709 'request_context' => $this->get_result_request_context( $request_context ),
1710 ]
1711 );
1712 }
1713
1714 $this->backfill_source_meta( $attachment_id, $url, $rel_path );
1715 if ( $fingerprint ) {
1716 $this->backfill_fingerprint_meta( $attachment_id, $fingerprint );
1717 }
1718
1719 return $this->build_item_result(
1720 'SKIPPED',
1721 $filename,
1722 $dry_run
1723 ? sprintf(
1724 /* translators: %d: attachment ID. */
1725 __( 'Dry run: duplicate detected (ID %d) and it would be skipped.', 'calliope-media-import-export' ),
1726 $attachment_id
1727 )
1728 : ( 'csv_id_match' === $reason
1729 ? sprintf(
1730 /* translators: %d: attachment ID. */
1731 __( 'Matched existing attachment (ID %d)', 'calliope-media-import-export' ),
1732 $attachment_id
1733 )
1734 : sprintf(
1735 /* translators: %d: attachment ID. */
1736 __( 'Duplicate detected (ID %d)', 'calliope-media-import-export' ),
1737 $attachment_id
1738 ) ),
1739 [
1740 'reason' => $dry_run ? 'dry_run_duplicate_skip' : (string) $reason,
1741 'attachment_id' => $attachment_id,
1742 'duplicate_detected' => true,
1743 ]
1744 );
1745 }
1746
1747 private function update_existing_attachment_metadata( $attachment_id, $filename, $title, $alt, $caption, $description, $url, $rel_path, $fingerprint, $custom_meta, $row, $selected_fields = [], $request_context = [] ) {
1748 $attachment_id = absint( $attachment_id );
1749 if ( ! $attachment_id ) {
1750 return;
1751 }
1752
1753 $selected_fields = $this->normalize_selected_update_fields( $selected_fields );
1754 $update_all = empty( $selected_fields );
1755 $action_context = $this->build_import_action_context(
1756 $request_context,
1757 $row,
1758 [
1759 'attachment_id' => $attachment_id,
1760 'action' => 'metadata_update',
1761 'filename' => (string) $filename,
1762 'selected_update_fields' => $selected_fields,
1763 'custom_meta_keys' => array_keys( is_array( $custom_meta ) ? $custom_meta : [] ),
1764 'source_url' => (string) $url,
1765 'relative_path' => (string) $rel_path,
1766 'fingerprint' => (string) $fingerprint,
1767 ]
1768 );
1769
1770 do_action( 'eim_before_update_existing_media', $attachment_id, $row, $action_context );
1771
1772 $post_data = [ 'ID' => $attachment_id ];
1773 $has_post_update = false;
1774
1775 $title = is_string( $title ) ? trim( $title ) : '';
1776 if ( ( $update_all || in_array( 'title', $selected_fields, true ) ) && '' !== $title ) {
1777 $post_data['post_title'] = $title;
1778 $has_post_update = true;
1779 }
1780
1781 $caption = is_string( $caption ) ? trim( $caption ) : '';
1782 if ( ( $update_all || in_array( 'caption', $selected_fields, true ) ) && '' !== $caption ) {
1783 $post_data['post_excerpt'] = $caption;
1784 $has_post_update = true;
1785 }
1786
1787 $description = is_string( $description ) ? trim( $description ) : '';
1788 if ( ( $update_all || in_array( 'description', $selected_fields, true ) ) && '' !== $description ) {
1789 $post_data['post_content'] = $description;
1790 $has_post_update = true;
1791 }
1792
1793 if ( $has_post_update ) {
1794 wp_update_post( $post_data );
1795 }
1796
1797 $alt = is_string( $alt ) ? trim( $alt ) : '';
1798 $mime = get_post_mime_type( $attachment_id );
1799 if ( ( $update_all || in_array( 'alt', $selected_fields, true ) ) && '' !== $alt && $mime && 0 === strpos( $mime, 'image/' ) ) {
1800 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
1801 }
1802
1803 if ( $update_all || in_array( 'custom_meta', $selected_fields, true ) ) {
1804 $this->apply_custom_meta( $attachment_id, $custom_meta );
1805 }
1806
1807 $this->backfill_source_meta( $attachment_id, $url, $rel_path );
1808 if ( $fingerprint ) {
1809 $this->backfill_fingerprint_meta( $attachment_id, $fingerprint );
1810 }
1811
1812 do_action( 'eim_after_update_existing_media', $attachment_id, $row );
1813 do_action( 'eim_after_update_existing_media_with_context', $attachment_id, $row, $action_context );
1814 }
1815
1816 private function replace_existing_attachment_file( $attachment_id, $source_file_path, $filename, $title, $alt, $caption, $description, $url, $rel_path, $fingerprint, $custom_meta, $row, $request_context = [] ) {
1817 $attachment_id = absint( $attachment_id );
1818 $source_file_path = (string) $source_file_path;
1819
1820 if ( ! $attachment_id || '' === $source_file_path || ! file_exists( $source_file_path ) ) {
1821 return new WP_Error( 'eim_replace_source_missing', __( 'The replacement source file is missing.', 'calliope-media-import-export' ) );
1822 }
1823
1824 $current_file = get_attached_file( $attachment_id );
1825 if ( ! $current_file ) {
1826 return new WP_Error( 'eim_replace_target_missing', __( 'The current attachment file could not be found.', 'calliope-media-import-export' ) );
1827 }
1828
1829 if ( wp_normalize_path( $source_file_path ) === wp_normalize_path( $current_file ) ) {
1830 $this->update_existing_attachment_metadata(
1831 $attachment_id,
1832 $filename,
1833 $title,
1834 $alt,
1835 $caption,
1836 $description,
1837 $url,
1838 $rel_path,
1839 $fingerprint,
1840 $custom_meta,
1841 $row,
1842 [],
1843 $request_context
1844 );
1845
1846 return $attachment_id;
1847 }
1848
1849 $target_dir = wp_normalize_path( dirname( $current_file ) );
1850 if ( ! is_dir( $target_dir ) || ! $this->is_path_inside_uploads( $target_dir ) ) {
1851 return new WP_Error( 'eim_replace_target_invalid', __( 'The target uploads directory is not valid.', 'calliope-media-import-export' ) );
1852 }
1853
1854 $target_name = sanitize_file_name( $filename ? $filename : wp_basename( $source_file_path ) );
1855 $source_is_svg = $this->is_svg_import_file( $source_file_path, $target_name );
1856 if ( $source_is_svg ) {
1857 $svg_validation = $this->maybe_validate_svg_import_file( $source_file_path, $target_name );
1858 if ( is_wp_error( $svg_validation ) ) {
1859 return $svg_validation;
1860 }
1861 }
1862
1863 $target_path = wp_normalize_path( trailingslashit( $target_dir ) . $target_name );
1864
1865 if ( $target_path !== wp_normalize_path( $current_file ) ) {
1866 $unique_name = wp_unique_filename( $target_dir, $target_name );
1867 $target_path = wp_normalize_path( trailingslashit( $target_dir ) . $unique_name );
1868 }
1869
1870 $action_context = $this->build_import_action_context(
1871 $request_context,
1872 $row,
1873 [
1874 'attachment_id' => $attachment_id,
1875 'action' => 'file_replace',
1876 'source_file_path' => wp_normalize_path( $source_file_path ),
1877 'current_file' => wp_normalize_path( $current_file ),
1878 'target_file' => wp_normalize_path( $target_path ),
1879 'filename' => (string) $filename,
1880 'source_url' => (string) $url,
1881 'relative_path' => (string) $rel_path,
1882 'fingerprint' => (string) $fingerprint,
1883 ]
1884 );
1885
1886 do_action( 'eim_before_replace_existing_media_file', $attachment_id, $source_file_path, $row, $action_context );
1887
1888 if ( ! @copy( $source_file_path, $target_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1889 return new WP_Error( 'eim_replace_copy_failed', __( 'The replacement file could not be copied into uploads.', 'calliope-media-import-export' ) );
1890 }
1891
1892 $old_metadata = wp_get_attachment_metadata( $attachment_id );
1893
1894 update_attached_file( $attachment_id, $target_path );
1895
1896 $filetype = $source_is_svg
1897 ? [
1898 'type' => 'image/svg+xml',
1899 'ext' => 'svg',
1900 ]
1901 : wp_check_filetype( wp_basename( $target_path ) );
1902 if ( ! empty( $filetype['type'] ) ) {
1903 wp_update_post(
1904 [
1905 'ID' => $attachment_id,
1906 'post_mime_type' => $filetype['type'],
1907 ]
1908 );
1909 }
1910
1911 if ( $source_is_svg ) {
1912 wp_update_attachment_metadata( $attachment_id, [] );
1913 } else {
1914 $attach_data = wp_generate_attachment_metadata( $attachment_id, $target_path );
1915 if ( ! empty( $attach_data ) && ! is_wp_error( $attach_data ) ) {
1916 wp_update_attachment_metadata( $attachment_id, $attach_data );
1917 }
1918 }
1919
1920 $this->update_existing_attachment_metadata(
1921 $attachment_id,
1922 $filename,
1923 $title,
1924 $alt,
1925 $caption,
1926 $description,
1927 $url,
1928 $rel_path,
1929 $fingerprint,
1930 $custom_meta,
1931 $row,
1932 [],
1933 $request_context
1934 );
1935
1936 $this->cleanup_attachment_generated_files( $current_file, $old_metadata );
1937
1938 do_action( 'eim_after_replace_existing_media_file', $attachment_id, $row, $action_context );
1939
1940 return $attachment_id;
1941 }
1942
1943 private function cleanup_attachment_generated_files( $current_file, $metadata ) {
1944 $current_file = wp_normalize_path( (string) $current_file );
1945 if ( '' === $current_file ) {
1946 return;
1947 }
1948
1949 $base_dir = wp_normalize_path( dirname( $current_file ) );
1950 $sizes = isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ? $metadata['sizes'] : [];
1951
1952 foreach ( $sizes as $size ) {
1953 if ( empty( $size['file'] ) ) {
1954 continue;
1955 }
1956
1957 $candidate = wp_normalize_path( trailingslashit( $base_dir ) . $size['file'] );
1958 if ( file_exists( $candidate ) ) {
1959 wp_delete_file( $candidate );
1960 }
1961 }
1962
1963 if ( file_exists( $current_file ) ) {
1964 wp_delete_file( $current_file );
1965 }
1966 }
1967
1968 private function apply_custom_meta( $attachment_id, $custom_meta ) {
1969 $attachment_id = absint( $attachment_id );
1970 $custom_meta = is_array( $custom_meta ) ? $custom_meta : [];
1971
1972 if ( ! $attachment_id || empty( $custom_meta ) ) {
1973 return;
1974 }
1975
1976 foreach ( $custom_meta as $meta_key => $meta_value ) {
1977 $meta_key = sanitize_key( (string) $meta_key );
1978
1979 if ( '' === $meta_key ) {
1980 continue;
1981 }
1982
1983 update_post_meta( $attachment_id, $meta_key, is_scalar( $meta_value ) ? (string) $meta_value : wp_json_encode( $meta_value ) );
1984 }
1985 }
1986
1987 private function decode_custom_meta_json( $value ) {
1988 if ( is_array( $value ) ) {
1989 return $value;
1990 }
1991
1992 $decoded = json_decode( (string) $value, true );
1993
1994 return is_array( $decoded ) ? $decoded : [];
1995 }
1996
1997 private function store_source_meta( $attachment_id, $url, $rel_path ) {
1998 $attachment_id = absint( $attachment_id );
1999 if ( ! $attachment_id ) {
2000 return;
2001 }
2002
2003 if ( is_string( $url ) && '' !== trim( $url ) ) {
2004 update_post_meta( $attachment_id, '_eim_source_url', trim( $url ) );
2005 }
2006
2007 if ( is_string( $rel_path ) && '' !== trim( $rel_path ) ) {
2008 update_post_meta( $attachment_id, '_eim_source_rel_path', trim( $rel_path ) );
2009 }
2010 }
2011
2012 private function backfill_source_meta( $attachment_id, $url, $rel_path ) {
2013 $attachment_id = absint( $attachment_id );
2014 if ( ! $attachment_id ) {
2015 return;
2016 }
2017
2018 $current_url = (string) get_post_meta( $attachment_id, '_eim_source_url', true );
2019 if ( '' === $current_url && is_string( $url ) && '' !== trim( $url ) ) {
2020 update_post_meta( $attachment_id, '_eim_source_url', trim( $url ) );
2021 }
2022
2023 $current_rel = (string) get_post_meta( $attachment_id, '_eim_source_rel_path', true );
2024 if ( '' === $current_rel && is_string( $rel_path ) && '' !== trim( $rel_path ) ) {
2025 update_post_meta( $attachment_id, '_eim_source_rel_path', trim( $rel_path ) );
2026 }
2027 }
2028
2029 private function store_fingerprint_meta( $attachment_id, $fingerprint ) {
2030 $attachment_id = absint( $attachment_id );
2031 $fingerprint = is_string( $fingerprint ) ? trim( $fingerprint ) : '';
2032
2033 if ( ! $attachment_id || '' === $fingerprint ) {
2034 return;
2035 }
2036
2037 update_post_meta( $attachment_id, '_eim_file_fingerprint', $fingerprint );
2038
2039 if ( 0 === strpos( $fingerprint, 'md5:' ) ) {
2040 $md5 = substr( $fingerprint, 4 );
2041 if ( $md5 ) {
2042 update_post_meta( $attachment_id, '_eim_file_hash', $md5 );
2043 }
2044 }
2045 }
2046
2047 private function backfill_fingerprint_meta( $attachment_id, $fingerprint ) {
2048 $attachment_id = absint( $attachment_id );
2049 $fingerprint = is_string( $fingerprint ) ? trim( $fingerprint ) : '';
2050
2051 if ( ! $attachment_id || '' === $fingerprint ) {
2052 return;
2053 }
2054
2055 $current = (string) get_post_meta( $attachment_id, '_eim_file_fingerprint', true );
2056 if ( '' === $current ) {
2057 $this->store_fingerprint_meta( $attachment_id, $fingerprint );
2058 }
2059 }
2060
2061 private function get_file_fingerprint( $file_path ) {
2062 $file_path = (string) $file_path;
2063
2064 if ( '' === $file_path || ! file_exists( $file_path ) ) {
2065 return '';
2066 }
2067
2068 $size = @filesize( $file_path );
2069 if ( false === $size ) {
2070 return '';
2071 }
2072
2073 $max_full_bytes = (int) apply_filters( 'eim_full_hash_max_bytes', 50 * 1024 * 1024 );
2074 $chunk_bytes = (int) apply_filters( 'eim_fingerprint_chunk_bytes', 1024 * 1024 );
2075
2076 if ( $size > 0 && $size <= $max_full_bytes ) {
2077 $md5 = @md5_file( $file_path );
2078 return $md5 ? 'md5:' . $md5 : '';
2079 }
2080
2081 $fingerprint = $this->compute_large_file_fingerprint( $file_path, (int) $size, $chunk_bytes );
2082 return $fingerprint ? 'fp:' . $fingerprint : '';
2083 }
2084
2085 private function compute_large_file_fingerprint( $file_path, $size, $chunk_bytes ) {
2086 $size = (int) $size;
2087 $chunk_bytes = max( 1024, (int) $chunk_bytes );
2088
2089 $handle = $this->open_read_handle( $file_path );
2090 if ( ! $handle ) {
2091 return '';
2092 }
2093
2094 $first = $this->read_file_chunk( $handle, $chunk_bytes );
2095 $first_md5 = false !== $first ? md5( $first ) : '';
2096 $last_md5 = '';
2097
2098 if ( $size > $chunk_bytes ) {
2099 @fseek( $handle, -$chunk_bytes, SEEK_END );
2100 $last = $this->read_file_chunk( $handle, $chunk_bytes );
2101 $last_md5 = false !== $last ? md5( $last ) : '';
2102 } else {
2103 $last_md5 = $first_md5;
2104 }
2105
2106 $this->close_file_handle( $handle );
2107
2108 if ( '' === $first_md5 || '' === $last_md5 ) {
2109 return '';
2110 }
2111
2112 return sha1( $size . '|' . $first_md5 . '|' . $last_md5 );
2113 }
2114
2115 private function normalize_import_filename( $filename, $url = '', $rel_path = '' ) {
2116 $filename = is_string( $filename ) ? trim( $filename ) : '';
2117
2118 if ( '' === $filename ) {
2119 $filename = $this->derive_filename( (string) $url, (string) $rel_path );
2120 }
2121
2122 $filename = wp_basename( strtok( (string) $filename, '?#' ) );
2123 $filename = remove_accents( $filename );
2124 $filename = sanitize_file_name( $filename );
2125
2126 $clean = preg_replace( '/[^A-Za-z0-9._-]+/', '-', $filename );
2127 if ( is_string( $clean ) && '' !== $clean ) {
2128 $filename = $clean;
2129 }
2130
2131 $filename = preg_replace( '/\.{2,}/', '.', $filename );
2132 $filename = preg_replace( '/[-_]{2,}/', '-', $filename );
2133 $filename = preg_replace( '/[-_]+\./', '.', $filename );
2134 $filename = preg_replace( '/\.[-_]+/', '.', $filename );
2135 $filename = trim( (string) $filename, ".-_ \t\n\r\0\x0B" );
2136
2137 if ( '' === $filename ) {
2138 $filename = 'media-file';
2139 }
2140
2141 $max_length = (int) apply_filters( 'eim_import_max_filename_length', 120 );
2142 $max_length = max( 60, min( 180, $max_length ) );
2143
2144 return $this->truncate_filename_preserving_extension( $filename, $max_length );
2145 }
2146
2147 private function truncate_filename_preserving_extension( $filename, $max_length ) {
2148 $filename = (string) $filename;
2149 $max_length = max( 60, (int) $max_length );
2150
2151 if ( strlen( $filename ) <= $max_length ) {
2152 return $filename;
2153 }
2154
2155 $info = pathinfo( $filename );
2156 $ext = '';
2157 if ( ! empty( $info['extension'] ) ) {
2158 $ext = '.' . strtolower( preg_replace( '/[^A-Za-z0-9]+/', '', (string) $info['extension'] ) );
2159 }
2160
2161 $base = isset( $info['filename'] ) && '' !== $info['filename'] ? (string) $info['filename'] : 'media-file';
2162 $hash = substr( sha1( $filename ), 0, 8 );
2163 $room = $max_length - strlen( $ext ) - strlen( $hash ) - 1;
2164 $room = max( 20, $room );
2165 $base = $this->truncate_string_bytes( $base, $room );
2166 $base = trim( (string) $base, '.-_' );
2167
2168 if ( '' === $base ) {
2169 $base = 'media-file';
2170 }
2171
2172 return $base . '-' . $hash . $ext;
2173 }
2174
2175 private function truncate_string_bytes( $string, $max_bytes ) {
2176 $string = (string) $string;
2177 $max_bytes = max( 1, (int) $max_bytes );
2178
2179 if ( strlen( $string ) <= $max_bytes ) {
2180 return $string;
2181 }
2182
2183 if ( function_exists( 'mb_strcut' ) ) {
2184 return mb_strcut( $string, 0, $max_bytes, 'UTF-8' );
2185 }
2186
2187 return substr( $string, 0, $max_bytes );
2188 }
2189
2190 private function sanitize_relative_path( $rel_path ) {
2191 $rel_path = (string) $rel_path;
2192 $rel_path = str_replace( '\\', '/', $rel_path );
2193 $rel_path = trim( $rel_path );
2194
2195 if ( '' === $rel_path ) {
2196 return '';
2197 }
2198
2199 $rel_path = strtok( $rel_path, '?#' );
2200 if ( preg_match( '/[\x00-\x1F\x7F]/', $rel_path ) ) {
2201 return '';
2202 }
2203
2204 if ( preg_match( '#^[a-zA-Z]:#', $rel_path ) ) {
2205 return '';
2206 }
2207
2208 $rel_path = ltrim( $rel_path, '/' );
2209 $rel_path = preg_replace( '#/+#', '/', $rel_path );
2210
2211 $segments = explode( '/', $rel_path );
2212 $safe = [];
2213
2214 foreach ( $segments as $segment ) {
2215 $segment = trim( (string) $segment );
2216
2217 if ( '' === $segment || '.' === $segment ) {
2218 continue;
2219 }
2220
2221 if ( '..' === $segment ) {
2222 return '';
2223 }
2224
2225 $safe[] = $segment;
2226 }
2227
2228 return implode( '/', $safe );
2229 }
2230
2231 private function resolve_uploads_file_from_source( $url = '', $rel_path = '' ) {
2232 $candidates = [];
2233 $rel_path = $this->sanitize_relative_path( $rel_path );
2234
2235 if ( '' !== $rel_path ) {
2236 $candidates[] = $this->build_uploads_candidate_path( $rel_path );
2237 }
2238
2239 $url_rel_path = $this->get_uploads_relative_path_from_url( $url );
2240 if ( '' !== $url_rel_path ) {
2241 $candidates[] = $this->build_uploads_candidate_path( $url_rel_path );
2242 }
2243
2244 $candidates = array_values( array_unique( array_filter( $candidates ) ) );
2245 foreach ( $candidates as $candidate ) {
2246 if ( file_exists( $candidate ) && $this->is_path_inside_uploads( $candidate ) ) {
2247 return $candidate;
2248 }
2249 }
2250
2251 return '';
2252 }
2253
2254 private function build_uploads_candidate_path( $rel_path ) {
2255 $rel_path = $this->sanitize_relative_path( $rel_path );
2256 if ( '' === $rel_path ) {
2257 return '';
2258 }
2259
2260 $upload_dir = wp_upload_dir();
2261 if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) ) {
2262 return '';
2263 }
2264
2265 return wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) . $rel_path );
2266 }
2267
2268 private function get_uploads_relative_path_from_url( $url ) {
2269 $url = is_string( $url ) ? trim( $url ) : '';
2270 if ( '' === $url ) {
2271 return '';
2272 }
2273
2274 $url_parts = wp_parse_url( $url );
2275 if ( empty( $url_parts['path'] ) ) {
2276 return '';
2277 }
2278
2279 $upload_dir = wp_upload_dir();
2280 if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['baseurl'] ) ) {
2281 return '';
2282 }
2283
2284 $base_parts = wp_parse_url( $upload_dir['baseurl'] );
2285 if ( ! $this->url_parts_match_host( $url_parts, $base_parts ) ) {
2286 return '';
2287 }
2288
2289 $url_path = '/' . ltrim( rawurldecode( str_replace( '\\', '/', (string) $url_parts['path'] ) ), '/' );
2290 $url_path = preg_replace( '#/+#', '/', $url_path );
2291 $base_path = isset( $base_parts['path'] ) ? '/' . trim( rawurldecode( str_replace( '\\', '/', (string) $base_parts['path'] ) ), '/' ) : '';
2292 $base_path = preg_replace( '#/+#', '/', $base_path );
2293
2294 if ( '' !== $base_path && 0 === strpos( $url_path . '/', trailingslashit( $base_path ) ) ) {
2295 return $this->sanitize_relative_path( substr( $url_path, strlen( $base_path ) ) );
2296 }
2297
2298 $marker = '/wp-content/uploads/';
2299 $pos = strpos( $url_path, $marker );
2300
2301 if ( false !== $pos ) {
2302 return $this->sanitize_relative_path( substr( $url_path, $pos + strlen( $marker ) ) );
2303 }
2304
2305 return '';
2306 }
2307
2308 private function looks_like_local_upload_url( $url ) {
2309 return '' !== $this->get_uploads_relative_path_from_url( $url );
2310 }
2311
2312 private function url_parts_match_host( $url_parts, $base_parts ) {
2313 $url_host = isset( $url_parts['host'] ) ? strtolower( (string) $url_parts['host'] ) : '';
2314 $base_host = isset( $base_parts['host'] ) ? strtolower( (string) $base_parts['host'] ) : '';
2315
2316 if ( '' === $url_host || '' === $base_host || $url_host !== $base_host ) {
2317 return false;
2318 }
2319
2320 $url_port = isset( $url_parts['port'] ) ? (int) $url_parts['port'] : 0;
2321 $base_port = isset( $base_parts['port'] ) ? (int) $base_parts['port'] : 0;
2322
2323 return 0 === $url_port || 0 === $base_port || $url_port === $base_port;
2324 }
2325
2326 private function is_path_inside_uploads( $file_path ) {
2327 $upload_dir = wp_upload_dir();
2328 $base = realpath( $upload_dir['basedir'] );
2329 $real = realpath( $file_path );
2330
2331 if ( ! $base || ! $real ) {
2332 return false;
2333 }
2334
2335 $base = trailingslashit( wp_normalize_path( $base ) );
2336 $real = wp_normalize_path( $real );
2337
2338 return ( $real === untrailingslashit( $base ) || 0 === strpos( $real, $base ) );
2339 }
2340
2341 private function get_import_allowed_mimes() {
2342 $allowed_mimes = get_allowed_mime_types();
2343
2344 if ( apply_filters( 'eim_allow_svg_imports', true ) ) {
2345 $allowed_mimes['svg'] = 'image/svg+xml';
2346 }
2347
2348 return apply_filters( 'eim_allowed_import_mimes', $allowed_mimes );
2349 }
2350
2351 private function is_svg_import_file( $file_path, $filename = '' ) {
2352 $filename = strtolower( (string) $filename );
2353 $file_path = (string) $file_path;
2354
2355 if ( '' !== $filename && preg_match( '/\.svg$/i', $filename ) ) {
2356 return true;
2357 }
2358
2359 if ( '' !== $file_path && preg_match( '/\.svg$/i', $file_path ) ) {
2360 return true;
2361 }
2362
2363 if ( '' === $file_path || ! is_readable( $file_path ) ) {
2364 return false;
2365 }
2366
2367 $contents = file_get_contents( $file_path, false, null, 0, 512 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2368 return is_string( $contents ) && false !== stripos( $contents, '<svg' );
2369 }
2370
2371 private function maybe_validate_svg_import_file( $file_path, $filename = '' ) {
2372 if ( ! $this->is_svg_import_file( $file_path, $filename ) ) {
2373 return true;
2374 }
2375
2376 if ( ! apply_filters( 'eim_allow_svg_imports', true, $file_path, $filename ) ) {
2377 return new WP_Error( 'eim_svg_import_disabled', __( 'SVG imports are disabled.', 'calliope-media-import-export' ) );
2378 }
2379
2380 return $this->validate_safe_svg_file( $file_path );
2381 }
2382
2383 private function validate_safe_svg_file( $file_path ) {
2384 $file_path = (string) $file_path;
2385
2386 if ( '' === $file_path || ! is_readable( $file_path ) ) {
2387 return new WP_Error( 'eim_svg_unreadable', __( 'SVG file could not be read.', 'calliope-media-import-export' ) );
2388 }
2389
2390 $contents = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
2391 if ( ! is_string( $contents ) || '' === trim( $contents ) ) {
2392 return new WP_Error( 'eim_svg_empty', __( 'SVG file is empty.', 'calliope-media-import-export' ) );
2393 }
2394
2395 if ( ! preg_match( '/<\s*svg\b/i', $contents ) ) {
2396 return new WP_Error( 'eim_svg_invalid', __( 'SVG file does not contain valid SVG markup.', 'calliope-media-import-export' ) );
2397 }
2398
2399 $unsafe_patterns = [
2400 '/<\s*script\b/i',
2401 '/\son[a-z0-9_-]+\s*=/i',
2402 '/javascript\s*:/i',
2403 '/<\s*foreignObject\b/i',
2404 '/<\s*(iframe|object|embed|link|meta|base|form|input|textarea|button|select)\b/i',
2405 '/<\?xml-stylesheet\b/i',
2406 ];
2407
2408 foreach ( $unsafe_patterns as $pattern ) {
2409 if ( preg_match( $pattern, $contents ) ) {
2410 return new WP_Error( 'eim_svg_unsafe', __( 'SVG file contains potentially unsafe content.', 'calliope-media-import-export' ) );
2411 }
2412 }
2413
2414 return true;
2415 }
2416
2417 private function sideload_svg_file( $file_array, $subdir = '' ) {
2418 $tmp_name = isset( $file_array['tmp_name'] ) ? (string) $file_array['tmp_name'] : '';
2419 $filename = isset( $file_array['name'] ) ? sanitize_file_name( (string) $file_array['name'] ) : '';
2420
2421 if ( '' === $filename ) {
2422 $filename = 'media-file.svg';
2423 } elseif ( ! preg_match( '/\.svg$/i', $filename ) ) {
2424 $filename .= '.svg';
2425 }
2426
2427 $svg_validation = $this->maybe_validate_svg_import_file( $tmp_name, $filename );
2428 if ( is_wp_error( $svg_validation ) ) {
2429 return $svg_validation;
2430 }
2431
2432 $uploads = wp_upload_dir();
2433 if ( ! empty( $uploads['error'] ) ) {
2434 return new WP_Error( 'eim_upload_dir_error', $uploads['error'] );
2435 }
2436
2437 $subdir = trim( (string) $subdir );
2438 if ( '' !== $subdir ) {
2439 $subdir = '/' . trim( $subdir, '/' );
2440 }
2441
2442 $target_dir = '' !== $subdir ? trailingslashit( $uploads['basedir'] ) . ltrim( $subdir, '/' ) : $uploads['path'];
2443 $target_url = '' !== $subdir ? trailingslashit( $uploads['baseurl'] ) . ltrim( $subdir, '/' ) : $uploads['url'];
2444
2445 if ( false !== strpos( $subdir, '..' ) ) {
2446 return new WP_Error( 'eim_invalid_subdir', __( 'Invalid target folder.', 'calliope-media-import-export' ) );
2447 }
2448
2449 if ( ! file_exists( $target_dir ) && ! wp_mkdir_p( $target_dir ) ) {
2450 return new WP_Error( 'eim_upload_dir_error', __( 'Could not create the target upload folder.', 'calliope-media-import-export' ) );
2451 }
2452
2453 $unique_name = wp_unique_filename( $target_dir, $filename );
2454 $target_path = wp_normalize_path( trailingslashit( $target_dir ) . $unique_name );
2455
2456 if ( ! @rename( $tmp_name, $target_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2457 if ( ! @copy( $tmp_name, $target_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2458 return new WP_Error( 'eim_svg_copy_failed', __( 'The SVG file could not be copied into uploads.', 'calliope-media-import-export' ) );
2459 }
2460
2461 wp_delete_file( $tmp_name );
2462 }
2463
2464 $stat = stat( dirname( $target_path ) );
2465 $perms = $stat ? $stat['mode'] & 0000666 : 0644;
2466 chmod( $target_path, $perms );
2467
2468 $attachment = [
2469 'guid' => trailingslashit( $target_url ) . wp_basename( $target_path ),
2470 'post_mime_type' => 'image/svg+xml',
2471 'post_title' => preg_replace( '/\.[^.]+$/', '', wp_basename( $target_path ) ),
2472 'post_content' => '',
2473 'post_status' => 'inherit',
2474 ];
2475
2476 $id = wp_insert_attachment( $attachment, $target_path, 0 );
2477 if ( is_wp_error( $id ) ) {
2478 wp_delete_file( $target_path );
2479 return $id;
2480 }
2481
2482 update_attached_file( $id, $target_path );
2483 wp_update_attachment_metadata( $id, [] );
2484
2485 return $id;
2486 }
2487
2488 private function media_handle_sideload_with_subdir( $file_array, $subdir = '' ) {
2489 $subdir = trim( (string) $subdir );
2490
2491 if ( isset( $file_array['name'] ) ) {
2492 $file_array['name'] = $this->normalize_import_filename( $file_array['name'] );
2493 }
2494
2495 if ( isset( $file_array['tmp_name'] ) && $this->is_svg_import_file( $file_array['tmp_name'], isset( $file_array['name'] ) ? $file_array['name'] : '' ) ) {
2496 return $this->sideload_svg_file( $file_array, $subdir );
2497 }
2498
2499 if ( '' === $subdir ) {
2500 return media_handle_sideload( $file_array, 0 );
2501 }
2502
2503 $subdir = '/' . trim( $subdir, '/' );
2504
2505 if ( false !== strpos( $subdir, '..' ) ) {
2506 return new WP_Error( 'eim_invalid_subdir', __( 'Invalid target folder.', 'calliope-media-import-export' ) );
2507 }
2508
2509 $uploads = wp_upload_dir();
2510 if ( ! empty( $uploads['error'] ) ) {
2511 return new WP_Error( 'eim_upload_dir_error', $uploads['error'] );
2512 }
2513
2514 $target_dir = trailingslashit( $uploads['basedir'] ) . ltrim( $subdir, '/' );
2515 if ( ! file_exists( $target_dir ) && ! wp_mkdir_p( $target_dir ) ) {
2516 return new WP_Error( 'eim_upload_dir_error', __( 'Could not create the target upload folder.', 'calliope-media-import-export' ) );
2517 }
2518
2519 $filter = function( $upload_paths ) use ( $subdir ) {
2520 $upload_paths['subdir'] = $subdir;
2521 $upload_paths['path'] = $upload_paths['basedir'] . $subdir;
2522 $upload_paths['url'] = $upload_paths['baseurl'] . $subdir;
2523 return $upload_paths;
2524 };
2525
2526 add_filter( 'upload_dir', $filter );
2527 $id = media_handle_sideload( $file_array, 0 );
2528 remove_filter( 'upload_dir', $filter );
2529
2530 return $id;
2531 }
2532
2533 private function map_headers( $headers ) {
2534 $map = [];
2535 $headers = array_map( 'strtolower', array_map( 'trim', $headers ) );
2536 $definitions = $this->get_import_header_definitions();
2537
2538 foreach ( $headers as $index => $header ) {
2539 foreach ( $definitions as $key => $options ) {
2540 $aliases = isset( $options['aliases'] ) ? (array) $options['aliases'] : [];
2541 if ( isset( $options['label'] ) ) {
2542 $aliases[] = (string) $options['label'];
2543 }
2544 $aliases = array_map( 'strtolower', array_map( 'trim', $aliases ) );
2545
2546 if ( in_array( $header, $aliases, true ) ) {
2547 $map[ $key ] = $index;
2548 break;
2549 }
2550 }
2551 }
2552
2553 return $map;
2554 }
2555
2556 private function build_row_from_csv( $row_data, $header_map ) {
2557 $row = [];
2558
2559 foreach ( $header_map as $key => $index ) {
2560 $row[ $key ] = isset( $row_data[ $index ] ) ? $row_data[ $index ] : '';
2561 }
2562
2563 return $row;
2564 }
2565
2566 private function ensure_ajax_permissions() {
2567 check_ajax_referer( 'eim_import_nonce', 'nonce' );
2568
2569 if ( ! eim_current_user_can_manage() ) {
2570 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'calliope-media-import-export' ) ], 403 );
2571 }
2572 }
2573
2574 private function normalize_import_request_context( $args = [] ) {
2575 $defaults = [
2576 'start_row' => 0,
2577 'batch_size' => (int) eim_get_setting( 'import.default_batch_size', 25 ),
2578 'local_import' => false,
2579 'skip_thumbnails' => false,
2580 'honor_relative_path' => true,
2581 'dry_run' => false,
2582 'duplicate_strategy' => 'skip',
2583 'match_strategy' => 'auto',
2584 'selected_update_fields' => [],
2585 'pro_history_id' => 0,
2586 'pro_job_id' => 0,
2587 'convert_images_format' => 'keep',
2588 'conversion_quality' => 82,
2589 'conversion_failure_behavior' => 'keep_original',
2590 'source' => 'runtime',
2591 'file' => '',
2592 ];
2593
2594 $context = wp_parse_args( is_array( $args ) ? $args : [], $defaults );
2595 $context['start_row'] = max( 0, absint( $context['start_row'] ) );
2596 $context['batch_size'] = isset( $context['batch_size'] ) ? absint( $context['batch_size'] ) : 0;
2597 $context['batch_size'] = $context['batch_size'] > self::MAX_BATCH_SIZE ? self::MAX_BATCH_SIZE : $context['batch_size'];
2598 $context['source'] = sanitize_key( (string) $context['source'] );
2599 $context['file'] = sanitize_file_name( (string) $context['file'] );
2600 $context['advanced_import_actions_allowed'] = $this->advanced_import_actions_allowed( $context, $args );
2601 $context['local_import'] = ! empty( $context['local_import'] );
2602 $context['skip_thumbnails'] = ! empty( $context['skip_thumbnails'] );
2603 $context['honor_relative_path'] = ! isset( $context['honor_relative_path'] ) || ! empty( $context['honor_relative_path'] );
2604 $context['dry_run'] = ! empty( $context['dry_run'] ) && ! empty( $context['advanced_import_actions_allowed'] );
2605 $context['duplicate_strategy'] = $this->normalize_duplicate_strategy( $context['duplicate_strategy'], ! empty( $context['advanced_import_actions_allowed'] ) );
2606 $context['match_strategy'] = $this->normalize_match_strategy( $context['match_strategy'], ! empty( $context['advanced_import_actions_allowed'] ) );
2607 $context['selected_update_fields'] = ! empty( $context['advanced_import_actions_allowed'] )
2608 ? $this->normalize_selected_update_fields( $context['selected_update_fields'] )
2609 : [];
2610 $context['pro_history_id'] = ! empty( $context['advanced_import_actions_allowed'] ) ? absint( $context['pro_history_id'] ) : 0;
2611 $context['pro_job_id'] = ! empty( $context['advanced_import_actions_allowed'] ) ? absint( $context['pro_job_id'] ) : 0;
2612
2613 $conversion_format = sanitize_key( (string) $context['convert_images_format'] );
2614 $context['convert_images_format'] = ( ! empty( $context['advanced_import_actions_allowed'] ) && in_array( $conversion_format, [ 'keep', 'webp', 'avif' ], true ) )
2615 ? $conversion_format
2616 : 'keep';
2617 $quality = isset( $context['conversion_quality'] ) ? absint( $context['conversion_quality'] ) : 82;
2618 $context['conversion_quality'] = min( 100, max( 1, $quality ? $quality : 82 ) );
2619 $failure_behavior = sanitize_key( (string) $context['conversion_failure_behavior'] );
2620 $context['conversion_failure_behavior'] = ( ! empty( $context['advanced_import_actions_allowed'] ) && in_array( $failure_behavior, [ 'keep_original', 'fail_row' ], true ) )
2621 ? $failure_behavior
2622 : 'keep_original';
2623
2624 $context['batch_size'] = $this->get_safe_batch_size_for_context( $context );
2625
2626 return apply_filters( 'eim_import_request_context', $context, $args );
2627 }
2628
2629 private function get_safe_batch_size_for_context( $context ) {
2630 $batch_size = isset( $context['batch_size'] ) ? absint( $context['batch_size'] ) : 0;
2631 if ( $batch_size <= 0 ) {
2632 $batch_size = absint( eim_get_setting( 'import.default_batch_size', 25 ) );
2633 }
2634
2635 $batch_size = min( self::MAX_BATCH_SIZE, max( 1, $batch_size ) );
2636 $safe_limit = self::MAX_BATCH_SIZE;
2637
2638 if ( ! empty( $context['advanced_import_actions_allowed'] ) ) {
2639 $strategy = isset( $context['duplicate_strategy'] ) ? sanitize_key( (string) $context['duplicate_strategy'] ) : 'skip';
2640 $format = isset( $context['convert_images_format'] ) ? sanitize_key( (string) $context['convert_images_format'] ) : 'keep';
2641
2642 if ( 'avif' === $format ) {
2643 $safe_limit = 1;
2644 } elseif ( 'webp' === $format ) {
2645 $safe_limit = 5;
2646 } elseif ( 'replace_file' === $strategy ) {
2647 $safe_limit = 10;
2648 } elseif ( 'skip' !== $strategy ) {
2649 $safe_limit = 15;
2650 }
2651 }
2652
2653 return min( $batch_size, $safe_limit );
2654 }
2655
2656 private function advanced_import_actions_allowed( $context, $args = [] ) {
2657 return (bool) apply_filters(
2658 'eim_allow_advanced_import_actions',
2659 false,
2660 is_array( $context ) ? $context : [],
2661 is_array( $args ) ? $args : []
2662 );
2663 }
2664
2665 private function normalize_duplicate_strategy( $strategy, $allow_advanced = true ) {
2666 $strategy = sanitize_key( (string) $strategy );
2667 $allowed = $allow_advanced
2668 ? [ 'skip', 'update_metadata', 'update_selected_fields', 'replace_file', 'force_new' ]
2669 : [ 'skip' ];
2670
2671 if ( ! in_array( $strategy, $allowed, true ) ) {
2672 $strategy = 'skip';
2673 }
2674
2675 return $strategy;
2676 }
2677
2678 private function normalize_match_strategy( $strategy, $allow_advanced = true ) {
2679 $strategy = sanitize_key( (string) $strategy );
2680 $allowed = $allow_advanced
2681 ? [ 'auto', 'attachment_id', 'source_url', 'relative_path', 'filename' ]
2682 : [ 'auto' ];
2683
2684 if ( ! in_array( $strategy, $allowed, true ) ) {
2685 $strategy = 'auto';
2686 }
2687
2688 return $strategy;
2689 }
2690
2691 private function normalize_selected_update_fields( $fields ) {
2692 $allowed = [ 'title', 'alt', 'caption', 'description', 'custom_meta' ];
2693 $fields = is_array( $fields ) ? $fields : explode( ',', (string) $fields );
2694 $fields = array_values( array_unique( array_filter( array_map( 'sanitize_key', $fields ) ) ) );
2695
2696 return array_values( array_intersect( $fields, $allowed ) );
2697 }
2698
2699 private function can_attempt_match_without_source( $csv_id, $filename, $match_strategy ) {
2700 $match_strategy = $this->normalize_match_strategy( $match_strategy );
2701
2702 if ( 'attachment_id' === $match_strategy && absint( $csv_id ) ) {
2703 return true;
2704 }
2705
2706 if ( 'filename' === $match_strategy && '' !== trim( (string) $filename ) ) {
2707 return true;
2708 }
2709
2710 return false;
2711 }
2712
2713 private function get_request_array( $key ) {
2714 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX request is verified in ensure_ajax_permissions(); $key is an internal field name, not user-provided input.
2715 if ( ! isset( $_POST[ $key ] ) ) {
2716 return [];
2717 }
2718
2719 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce is verified in ensure_ajax_permissions(); array contents are sanitized below.
2720 $value = wp_unslash( $_POST[ $key ] );
2721
2722 if ( is_array( $value ) ) {
2723 return array_map( 'sanitize_key', $value );
2724 }
2725
2726 return array_map( 'sanitize_key', explode( ',', (string) $value ) );
2727 }
2728
2729 private function get_request_bool( $key, $default = false ) {
2730 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2731 if ( ! isset( $_POST[ $key ] ) ) {
2732 return (bool) $default;
2733 }
2734
2735 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2736 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AJAX request is verified in ensure_ajax_permissions().
2737 $value = filter_var( wp_unslash( $_POST[ $key ] ), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2738 if ( null === $value ) {
2739 return (bool) $default;
2740 }
2741
2742 return (bool) $value;
2743 }
2744
2745 private function get_request_absint( $key ) {
2746 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2747 if ( ! isset( $_POST[ $key ] ) ) {
2748 return 0;
2749 }
2750
2751 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2752 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AJAX request is verified in ensure_ajax_permissions().
2753 return absint( wp_unslash( $_POST[ $key ] ) );
2754 }
2755
2756 private function get_request_string( $key, $default = '' ) {
2757 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2758 if ( ! isset( $_POST[ $key ] ) ) {
2759 return (string) $default;
2760 }
2761
2762 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified in ensure_ajax_permissions().
2763 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- AJAX request is verified in ensure_ajax_permissions() and sanitized here.
2764 return sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
2765 }
2766
2767 private function get_bounded_batch_size() {
2768 $batch_size = $this->get_request_absint( 'batch_size' );
2769 if ( $batch_size <= 0 ) {
2770 $batch_size = absint( eim_get_setting( 'import.default_batch_size', 25 ) );
2771 }
2772
2773 return min( self::MAX_BATCH_SIZE, max( 1, $batch_size ) );
2774 }
2775
2776 private function get_batch_time_limit( $batch_size ) {
2777 $batch_size = absint( $batch_size );
2778
2779 if ( $batch_size >= 100 ) {
2780 $time_limit = 70;
2781 } elseif ( $batch_size >= 50 ) {
2782 $time_limit = 45;
2783 } elseif ( $batch_size >= 25 ) {
2784 $time_limit = 30;
2785 } else {
2786 $time_limit = 18;
2787 }
2788
2789 $server_limit = $this->get_server_execution_limit();
2790 if ( $server_limit > 0 && ! $this->can_extend_server_time_limit() ) {
2791 $safe_limit = max( 8, $server_limit - 5 );
2792 $time_limit = min( $time_limit, $safe_limit );
2793 }
2794
2795 /**
2796 * Filters the soft time limit, in seconds, for a single AJAX import batch.
2797 *
2798 * Return 0 to disable the plugin's soft limit and let PHP/server limits decide.
2799 *
2800 * @param int $time_limit Soft time limit in seconds.
2801 * @param int $batch_size Requested rows per batch.
2802 */
2803 return max( 0, absint( apply_filters( 'eim_import_batch_time_limit', $time_limit, $batch_size ) ) );
2804 }
2805
2806 private function get_batch_time_limit_for_context( $time_limit, $context ) {
2807 $context = is_array( $context ) ? $context : [];
2808 $time_limit = max( 0, absint( $time_limit ) );
2809 $batch_size = isset( $context['batch_size'] ) ? absint( $context['batch_size'] ) : 0;
2810
2811 if ( $this->can_extend_server_time_limit() ) {
2812 if ( $batch_size >= 50 ) {
2813 $time_limit = max( $time_limit, 180 );
2814 } elseif ( $batch_size >= 25 ) {
2815 $time_limit = max( $time_limit, 120 );
2816 } else {
2817 $time_limit = max( $time_limit, 60 );
2818 }
2819 }
2820
2821 /**
2822 * Filters the soft time limit, in seconds, for a single import batch after
2823 * the full request context is known.
2824 *
2825 * @param int $time_limit Soft time limit in seconds.
2826 * @param array $context Normalized import request context.
2827 */
2828 return max( 0, absint( apply_filters( 'eim_import_batch_time_limit_for_context', $time_limit, $context ) ) );
2829 }
2830
2831 private function should_stop_batch_before_next_row( $processed_batch, $start_time, $time_limit, $context ) {
2832 $processed_batch = absint( $processed_batch );
2833 $time_limit = absint( $time_limit );
2834
2835 if ( $processed_batch <= 0 || $time_limit <= 0 ) {
2836 return false;
2837 }
2838
2839 $elapsed = max( 0, time() - absint( $start_time ) );
2840 $guard = max( 3, min( 12, $this->get_download_timeout( $context ) + 2 ) );
2841
2842 return $elapsed >= max( 1, $time_limit - $guard );
2843 }
2844
2845 private function get_download_timeout( $context = [], $url = '' ) {
2846 $context = is_array( $context ) ? $context : [];
2847 $timeout = 5;
2848
2849 if ( ! empty( $context['local_import'] ) ) {
2850 $timeout = 3;
2851 }
2852
2853 if ( $this->looks_like_local_upload_url( $url ) ) {
2854 $timeout = 2;
2855 }
2856
2857 /**
2858 * Filters the HTTP timeout, in seconds, used to download a single remote
2859 * media file during CSV import.
2860 *
2861 * @param int $timeout Timeout in seconds.
2862 * @param array $context Normalized import request context.
2863 */
2864 return max( 1, min( 30, absint( apply_filters( 'eim_import_download_timeout', $timeout, $context, $url ) ) ) );
2865 }
2866
2867 private function can_extend_server_time_limit() {
2868 if ( ! function_exists( 'set_time_limit' ) ) {
2869 return false;
2870 }
2871
2872 $disabled_functions = (string) ini_get( 'disable_functions' );
2873 if ( '' === $disabled_functions ) {
2874 return true;
2875 }
2876
2877 $disabled_functions = array_map( 'trim', explode( ',', strtolower( $disabled_functions ) ) );
2878 return ! in_array( 'set_time_limit', $disabled_functions, true );
2879 }
2880
2881 private function get_server_execution_limit() {
2882 $limit = ini_get( 'max_execution_time' );
2883 if ( false === $limit || '' === $limit ) {
2884 return 0;
2885 }
2886
2887 $limit = absint( $limit );
2888 return $limit > 0 ? $limit : 0;
2889 }
2890
2891 private function extend_server_time_limit( $time_limit ) {
2892 if ( ! function_exists( 'set_time_limit' ) ) {
2893 return;
2894 }
2895
2896 $time_limit = absint( $time_limit );
2897 if ( $time_limit <= 0 ) {
2898 return;
2899 }
2900
2901 $target_limit = max( 120, $time_limit + 60 );
2902
2903 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Import batches need a little extra time when the host allows it.
2904 @set_time_limit( $target_limit );
2905 }
2906
2907 private function get_lock_ttl( $time_limit ) {
2908 $time_limit = absint( $time_limit );
2909 if ( $time_limit <= 0 ) {
2910 return self::LOCK_TTL;
2911 }
2912
2913 return max( self::LOCK_TTL, $time_limit + 45 );
2914 }
2915
2916 private function log_import_event( $event, $context = [] ) {
2917 $event = sanitize_key( (string) $event );
2918 $context = is_array( $context ) ? $context : [];
2919
2920 if ( '' === $event ) {
2921 $event = 'event';
2922 }
2923
2924 $encoded = wp_json_encode( $context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
2925 if ( false === $encoded ) {
2926 $encoded = '{}';
2927 }
2928
2929 error_log( '[EIM_IMPORT] ' . $event . ' | ' . $encoded );
2930 }
2931
2932 private function send_batch_error( $message, $status_code = 400 ) {
2933 $this->log_import_event(
2934 'batch_error_response',
2935 [
2936 'status_code' => absint( $status_code ),
2937 'message' => (string) $message,
2938 ]
2939 );
2940
2941 wp_send_json_error(
2942 [
2943 'message' => $message,
2944 'results' => [
2945 [
2946 'status' => 'ERROR',
2947 'file' => __( 'System', 'calliope-media-import-export' ),
2948 'message' => $message,
2949 ],
2950 ],
2951 ],
2952 $status_code
2953 );
2954 }
2955
2956 private function build_validation_preview( $inspection ) {
2957 $preview = [
2958 'delimiter' => isset( $inspection['delimiter'] ) ? (string) $inspection['delimiter'] : ',',
2959 'delimiter_label' => $this->get_delimiter_label( isset( $inspection['delimiter'] ) ? (string) $inspection['delimiter'] : ',' ),
2960 'header_count' => isset( $inspection['headers'] ) && is_array( $inspection['headers'] ) ? count( $inspection['headers'] ) : 0,
2961 'recognized_columns' => isset( $inspection['recognized_columns'] ) ? (array) $inspection['recognized_columns'] : [],
2962 'summary' => isset( $inspection['summary'] ) && is_array( $inspection['summary'] ) ? $inspection['summary'] : [],
2963 'sample_rows' => isset( $inspection['preview_rows'] ) ? (array) $inspection['preview_rows'] : [],
2964 'warnings' => isset( $inspection['warnings'] ) ? array_values( array_filter( (array) $inspection['warnings'] ) ) : [],
2965 ];
2966
2967 return apply_filters( 'eim_import_preview_data', $preview, $inspection );
2968 }
2969
2970 private function get_empty_result_summary() {
2971 return [
2972 'processed' => 0,
2973 'imported' => 0,
2974 'skipped' => 0,
2975 'errors' => 0,
2976 'processable' => 0,
2977 'duplicates' => 0,
2978 'updated' => 0,
2979 'restore_points_created' => 0,
2980 'converted_images' => 0,
2981 'conversion_warnings' => 0,
2982 'conversion_errors' => 0,
2983 'rollback_restored' => 0,
2984 'rollback_failures' => 0,
2985 ];
2986 }
2987
2988 private function increment_result_summary( $summary, $result ) {
2989 if ( ! is_array( $summary ) ) {
2990 $summary = $this->get_empty_result_summary();
2991 }
2992
2993 $summary['processed']++;
2994
2995 $status = isset( $result['status'] ) ? strtoupper( (string) $result['status'] ) : '';
2996 $reason = isset( $result['context']['reason'] ) ? (string) $result['context']['reason'] : '';
2997 if ( 'IMPORTED' === $status ) {
2998 $summary['imported']++;
2999 } elseif ( 'SKIPPED' === $status ) {
3000 $summary['skipped']++;
3001 } elseif ( 'ERROR' === $status ) {
3002 $summary['errors']++;
3003 } elseif ( 'READY' === $status ) {
3004 $summary['processable']++;
3005 }
3006
3007 if ( ! empty( $result['context']['duplicate_detected'] ) || in_array( $reason, [ 'duplicate_existing', 'csv_id_match', 'dry_run_duplicate_skip', 'dry_run_update_metadata', 'updated_metadata_only', 'dry_run_update_selected_fields', 'updated_selected_fields_only', 'dry_run_replace_file', 'replaced_existing_file' ], true ) ) {
3008 $summary['duplicates']++;
3009 }
3010
3011 if ( in_array( $reason, [ 'dry_run_update_metadata', 'updated_metadata_only', 'dry_run_update_selected_fields', 'updated_selected_fields_only', 'dry_run_replace_file', 'replaced_existing_file' ], true ) ) {
3012 $summary['updated']++;
3013 }
3014
3015 $context = isset( $result['context'] ) && is_array( $result['context'] ) ? $result['context'] : [];
3016 foreach ( [ 'restore_points_created', 'converted_images', 'conversion_warnings', 'conversion_errors', 'rollback_restored', 'rollback_failures' ] as $counter_key ) {
3017 if ( isset( $context[ $counter_key ] ) ) {
3018 $summary[ $counter_key ] += absint( $context[ $counter_key ] );
3019 }
3020 }
3021
3022 if ( ! empty( $context['restore_point_created'] ) ) {
3023 $summary['restore_points_created']++;
3024 }
3025
3026 if ( ! empty( $context['converted_image'] ) ) {
3027 $summary['converted_images']++;
3028 }
3029
3030 if ( ! empty( $context['conversion_warning'] ) ) {
3031 $summary['conversion_warnings']++;
3032 }
3033
3034 if ( ! empty( $context['conversion_error'] ) ) {
3035 $summary['conversion_errors']++;
3036 }
3037
3038 return $summary;
3039 }
3040
3041 private function build_batch_response( $results, $summary, $meta ) {
3042 $response = [
3043 'results' => is_array( $results ) ? array_values( $results ) : [],
3044 'summary' => is_array( $summary ) ? $summary : $this->get_empty_result_summary(),
3045 'meta' => is_array( $meta ) ? $meta : [],
3046 ];
3047
3048 return apply_filters( 'eim_import_batch_response', $response, $results, $summary, $meta );
3049 }
3050
3051 private function inspect_csv_file( $file_path ) {
3052 if ( ! is_string( $file_path ) || '' === $file_path || ! file_exists( $file_path ) ) {
3053 return new WP_Error( 'eim_csv_missing', __( 'Could not read the uploaded CSV file.', 'calliope-media-import-export' ) );
3054 }
3055
3056 $compatibility_error = $this->detect_incompatible_import_file( $file_path );
3057 if ( is_wp_error( $compatibility_error ) ) {
3058 return $compatibility_error;
3059 }
3060
3061 $delimiter = $this->detect_csv_delimiter( $file_path );
3062 if ( is_wp_error( $delimiter ) ) {
3063 return $delimiter;
3064 }
3065
3066 $handle = $this->open_read_handle( $file_path );
3067 if ( ! $handle ) {
3068 return new WP_Error( 'eim_csv_unreadable', __( 'Could not open the uploaded CSV file.', 'calliope-media-import-export' ) );
3069 }
3070
3071 $headers = $this->read_csv_row( $handle, $delimiter, true );
3072 if ( false === $headers || $this->is_csv_row_empty( $headers ) ) {
3073 $this->close_file_handle( $handle );
3074 return new WP_Error( 'eim_csv_empty', __( 'The CSV is empty.', 'calliope-media-import-export' ) );
3075 }
3076
3077 $header_map = $this->map_headers( $headers );
3078 if ( ! isset( $header_map['url'] ) && ! isset( $header_map['rel_path'] ) ) {
3079 $this->close_file_handle( $handle );
3080 return new WP_Error( 'eim_csv_invalid', __( 'Invalid CSV. Missing "Absolute URL" or "Relative Path" column.', 'calliope-media-import-export' ) );
3081 }
3082
3083 $summary = [
3084 'total_rows' => 0,
3085 'rows_with_url' => 0,
3086 'rows_with_relative_path' => 0,
3087 'rows_with_both' => 0,
3088 'rows_missing_source' => 0,
3089 'recommended_mode' => 'unknown',
3090 ];
3091 $preview_rows = [];
3092 $missing_row_index = [];
3093 $row_count = 0;
3094 $preview_limit = max( 1, absint( eim_get_setting( 'import.preview_sample_limit', 5 ) ) );
3095
3096 while ( false !== ( $row = $this->read_csv_row( $handle, $delimiter ) ) ) {
3097 if ( $this->is_csv_row_empty( $row ) ) {
3098 continue;
3099 }
3100
3101 $row_count++;
3102 $mapped_row = $this->build_row_from_csv( $row, $header_map );
3103 $summary = $this->accumulate_csv_summary( $summary, $mapped_row );
3104
3105 if ( ! empty( $summary['rows_missing_source'] ) && $summary['rows_missing_source'] === count( $missing_row_index ) + 1 && count( $missing_row_index ) < 3 ) {
3106 $missing_row_index[] = $row_count;
3107 }
3108
3109 if ( count( $preview_rows ) < $preview_limit ) {
3110 $preview_rows[] = $this->build_preview_row( $row_count, $mapped_row );
3111 }
3112 }
3113
3114 $this->close_file_handle( $handle );
3115
3116 if ( $row_count <= 0 ) {
3117 $fallback = $this->inspect_csv_file_with_normalized_line_endings( $file_path, $delimiter );
3118 if ( ! is_wp_error( $fallback ) && ! empty( $fallback['total_rows'] ) ) {
3119 return $fallback;
3120 }
3121
3122 $summary['recommended_mode'] = 'unknown';
3123
3124 return [
3125 'delimiter' => $delimiter,
3126 'headers' => $headers,
3127 'header_map' => $header_map,
3128 'recognized_columns' => $this->get_recognized_columns_for_preview( $header_map ),
3129 'total_rows' => 0,
3130 'summary' => $summary,
3131 'preview_rows' => [],
3132 'warnings' => [
3133 __( 'No importable media rows were found. The CSV may only contain headers, or your export filters may have matched no media items.', 'calliope-media-import-export' ),
3134 ],
3135 ];
3136 }
3137
3138 $summary['recommended_mode'] = $this->determine_recommended_source_mode( $summary );
3139
3140 return [
3141 'delimiter' => $delimiter,
3142 'headers' => $headers,
3143 'header_map' => $header_map,
3144 'recognized_columns' => $this->get_recognized_columns_for_preview( $header_map ),
3145 'total_rows' => $row_count,
3146 'summary' => $summary,
3147 'preview_rows' => $preview_rows,
3148 'warnings' => $this->build_csv_warnings( $summary, $header_map, $missing_row_index ),
3149 ];
3150 }
3151
3152 private function inspect_csv_file_with_normalized_line_endings( $file_path, $delimiter ) {
3153 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Fallback parser used only after stream parsing finds no rows.
3154 $contents = @file_get_contents( $file_path );
3155 if ( false === $contents || '' === $contents ) {
3156 return new WP_Error( 'eim_csv_empty', __( 'The CSV is empty.', 'calliope-media-import-export' ) );
3157 }
3158
3159 $contents = $this->strip_utf8_bom( (string) $contents );
3160 $contents = str_replace( [ "\r\n", "\r" ], "\n", $contents );
3161 $lines = preg_split( "/\n/", $contents );
3162 if ( ! is_array( $lines ) ) {
3163 return new WP_Error( 'eim_csv_no_rows', __( 'The CSV has headers but no data rows to import. Export or upload a CSV with at least one media row below the header.', 'calliope-media-import-export' ) );
3164 }
3165
3166 $rows = [];
3167 foreach ( $lines as $line ) {
3168 if ( '' === trim( (string) $line ) ) {
3169 continue;
3170 }
3171
3172 $line = (string) $line;
3173 if ( empty( $rows ) && preg_match( '/^sep=(.)\s*$/i', trim( $line ), $matches ) ) {
3174 $delimiter = '\t' === $matches[1] ? "\t" : $matches[1];
3175 continue;
3176 }
3177
3178 $rows[] = str_getcsv( $line, $delimiter );
3179 }
3180
3181 if ( empty( $rows ) ) {
3182 return new WP_Error( 'eim_csv_empty', __( 'The CSV is empty.', 'calliope-media-import-export' ) );
3183 }
3184
3185 $headers = array_shift( $rows );
3186 if ( $this->is_csv_row_empty( $headers ) ) {
3187 return new WP_Error( 'eim_csv_empty', __( 'The CSV is empty.', 'calliope-media-import-export' ) );
3188 }
3189
3190 $header_map = $this->map_headers( $headers );
3191 if ( ! isset( $header_map['url'] ) && ! isset( $header_map['rel_path'] ) ) {
3192 return new WP_Error( 'eim_csv_invalid', __( 'Invalid CSV. Missing "Absolute URL" or "Relative Path" column.', 'calliope-media-import-export' ) );
3193 }
3194
3195 $summary = [
3196 'total_rows' => 0,
3197 'rows_with_url' => 0,
3198 'rows_with_relative_path' => 0,
3199 'rows_with_both' => 0,
3200 'rows_missing_source' => 0,
3201 'recommended_mode' => 'unknown',
3202 ];
3203 $preview_rows = [];
3204 $missing_row_index = [];
3205 $row_count = 0;
3206 $preview_limit = max( 1, absint( eim_get_setting( 'import.preview_sample_limit', 5 ) ) );
3207
3208 foreach ( $rows as $row ) {
3209 if ( $this->is_csv_row_empty( $row ) ) {
3210 continue;
3211 }
3212
3213 $row_count++;
3214 $mapped_row = $this->build_row_from_csv( $row, $header_map );
3215 $summary = $this->accumulate_csv_summary( $summary, $mapped_row );
3216
3217 if ( ! empty( $summary['rows_missing_source'] ) && $summary['rows_missing_source'] === count( $missing_row_index ) + 1 && count( $missing_row_index ) < 3 ) {
3218 $missing_row_index[] = $row_count;
3219 }
3220
3221 if ( count( $preview_rows ) < $preview_limit ) {
3222 $preview_rows[] = $this->build_preview_row( $row_count, $mapped_row );
3223 }
3224 }
3225
3226 if ( $row_count <= 0 ) {
3227 $summary['recommended_mode'] = 'unknown';
3228
3229 return [
3230 'delimiter' => $delimiter,
3231 'headers' => $headers,
3232 'header_map' => $header_map,
3233 'recognized_columns' => $this->get_recognized_columns_for_preview( $header_map ),
3234 'total_rows' => 0,
3235 'summary' => $summary,
3236 'preview_rows' => [],
3237 'warnings' => [
3238 __( 'No importable media rows were found. The CSV may only contain headers, or your export filters may have matched no media items.', 'calliope-media-import-export' ),
3239 ],
3240 ];
3241 }
3242
3243 $summary['recommended_mode'] = $this->determine_recommended_source_mode( $summary );
3244
3245 return [
3246 'delimiter' => $delimiter,
3247 'headers' => $headers,
3248 'header_map' => $header_map,
3249 'recognized_columns' => $this->get_recognized_columns_for_preview( $header_map ),
3250 'total_rows' => $row_count,
3251 'summary' => $summary,
3252 'preview_rows' => $preview_rows,
3253 'warnings' => $this->build_csv_warnings( $summary, $header_map, $missing_row_index ),
3254 ];
3255 }
3256
3257 private function detect_incompatible_import_file( $file_path ) {
3258 $signature = $this->read_file_signature( $file_path, 4 );
3259
3260 if ( false !== $signature && in_array( $signature, [ "PK\x03\x04", "PK\x05\x06", "PK\x07\x08" ], true ) ) {
3261 return new WP_Error(
3262 'eim_csv_zip_upload',
3263 __( 'The selected file is a ZIP archive. The simple import tool expects a plain CSV file, not a ZIP or export bundle.', 'calliope-media-import-export' )
3264 );
3265 }
3266
3267 return null;
3268 }
3269
3270 private function detect_csv_delimiter( $file_path ) {
3271 $handle = $this->open_read_handle( $file_path );
3272 if ( ! $handle ) {
3273 return new WP_Error( 'eim_csv_unreadable', __( 'Could not open the uploaded CSV file.', 'calliope-media-import-export' ) );
3274 }
3275
3276 $sample_lines = [];
3277 while ( count( $sample_lines ) < 5 && false !== ( $line = fgets( $handle ) ) ) {
3278 $line = $this->strip_utf8_bom( (string) $line );
3279 if ( '' === trim( $line ) ) {
3280 continue;
3281 }
3282
3283 $sample_lines[] = $line;
3284 }
3285 $this->close_file_handle( $handle );
3286
3287 if ( empty( $sample_lines ) ) {
3288 return new WP_Error( 'eim_csv_empty', __( 'The CSV is empty.', 'calliope-media-import-export' ) );
3289 }
3290
3291 if ( isset( $sample_lines[0] ) && preg_match( '/^sep=(.)\s*$/i', trim( (string) $sample_lines[0] ), $matches ) ) {
3292 return '\t' === $matches[1] ? "\t" : $matches[1];
3293 }
3294
3295 $candidates = [ ',', ';', "\t", '|' ];
3296 $best = ',';
3297 $best_score = -1;
3298
3299 foreach ( $candidates as $candidate ) {
3300 $counts = [];
3301 $score = 0;
3302
3303 foreach ( $sample_lines as $line ) {
3304 $column_count = count( str_getcsv( $line, $candidate ) );
3305 $counts[] = $column_count;
3306 if ( $column_count > 1 ) {
3307 $score += $column_count;
3308 }
3309 }
3310
3311 if ( count( $counts ) === count( array_filter( $counts, function( $count ) {
3312 return $count > 1;
3313 } ) ) ) {
3314 $score += 100;
3315 }
3316
3317 if ( count( array_unique( $counts ) ) === 1 && ! empty( $counts ) && $counts[0] > 1 ) {
3318 $score += 200;
3319 }
3320
3321 if ( $score > $best_score ) {
3322 $best = $candidate;
3323 $best_score = $score;
3324 }
3325 }
3326
3327 return $best;
3328 }
3329
3330 private function read_file_signature( $file_path, $length = 4 ) {
3331 $length = max( 1, absint( $length ) );
3332 $handle = $this->open_read_handle( $file_path );
3333
3334 if ( ! $handle ) {
3335 return false;
3336 }
3337
3338 $signature = $this->read_file_chunk( $handle, $length );
3339 $this->close_file_handle( $handle );
3340
3341 return is_string( $signature ) ? $signature : false;
3342 }
3343
3344 private function get_delimiter_label( $delimiter ) {
3345 switch ( (string) $delimiter ) {
3346 case ';':
3347 return __( 'Semicolon (;)', 'calliope-media-import-export' );
3348 case "\t":
3349 return __( 'Tab', 'calliope-media-import-export' );
3350 case '|':
3351 return __( 'Pipe (|)', 'calliope-media-import-export' );
3352 case ',':
3353 default:
3354 return __( 'Comma (,)', 'calliope-media-import-export' );
3355 }
3356 }
3357
3358 private function read_csv_row( $handle, $delimiter, $strip_bom = false ) {
3359 if ( ! is_resource( $handle ) ) {
3360 return false;
3361 }
3362
3363 $row = fgetcsv( $handle, 0, $delimiter );
3364 if ( false === $row ) {
3365 return false;
3366 }
3367
3368 if ( $strip_bom && isset( $row[0] ) ) {
3369 $row[0] = $this->strip_utf8_bom( (string) $row[0] );
3370
3371 if ( 1 === count( $row ) && preg_match( '/^sep=.+$/i', trim( (string) $row[0] ) ) ) {
3372 $row = fgetcsv( $handle, 0, $delimiter );
3373 if ( false === $row ) {
3374 return false;
3375 }
3376
3377 if ( isset( $row[0] ) ) {
3378 $row[0] = $this->strip_utf8_bom( (string) $row[0] );
3379 }
3380 }
3381 }
3382
3383 return $row;
3384 }
3385
3386 private function strip_utf8_bom( $value ) {
3387 return preg_replace( '/^\xEF\xBB\xBF/', '', (string) $value );
3388 }
3389
3390 private function is_csv_row_empty( $row ) {
3391 if ( ! is_array( $row ) ) {
3392 return true;
3393 }
3394
3395 foreach ( $row as $value ) {
3396 if ( null !== $value && '' !== trim( (string) $value ) ) {
3397 return false;
3398 }
3399 }
3400
3401 return true;
3402 }
3403
3404 private function accumulate_csv_summary( $summary, $row ) {
3405 $summary = is_array( $summary ) ? $summary : [];
3406
3407 $url = isset( $row['url'] ) ? trim( (string) $row['url'] ) : '';
3408 $rel_path = isset( $row['rel_path'] ) ? trim( (string) $row['rel_path'] ) : '';
3409 $has_url = ( '' !== $url );
3410 $has_path = ( '' !== $rel_path );
3411
3412 $summary['total_rows'] = isset( $summary['total_rows'] ) ? absint( $summary['total_rows'] ) + 1 : 1;
3413
3414 if ( $has_url ) {
3415 $summary['rows_with_url'] = isset( $summary['rows_with_url'] ) ? absint( $summary['rows_with_url'] ) + 1 : 1;
3416 }
3417
3418 if ( $has_path ) {
3419 $summary['rows_with_relative_path'] = isset( $summary['rows_with_relative_path'] ) ? absint( $summary['rows_with_relative_path'] ) + 1 : 1;
3420 }
3421
3422 if ( $has_url && $has_path ) {
3423 $summary['rows_with_both'] = isset( $summary['rows_with_both'] ) ? absint( $summary['rows_with_both'] ) + 1 : 1;
3424 }
3425
3426 if ( ! $has_url && ! $has_path ) {
3427 $summary['rows_missing_source'] = isset( $summary['rows_missing_source'] ) ? absint( $summary['rows_missing_source'] ) + 1 : 1;
3428 }
3429
3430 return $summary;
3431 }
3432
3433 private function build_preview_row( $row_number, $row ) {
3434 return [
3435 'row_number' => absint( $row_number ),
3436 'source' => isset( $row['url'] ) ? trim( (string) $row['url'] ) : '',
3437 'relative_path' => isset( $row['rel_path'] ) ? trim( (string) $row['rel_path'] ) : '',
3438 'title' => isset( $row['title'] ) ? trim( (string) $row['title'] ) : '',
3439 'alt' => isset( $row['alt'] ) ? trim( (string) $row['alt'] ) : '',
3440 ];
3441 }
3442
3443 private function determine_recommended_source_mode( $summary ) {
3444 $with_url = isset( $summary['rows_with_url'] ) ? absint( $summary['rows_with_url'] ) : 0;
3445 $with_path = isset( $summary['rows_with_relative_path'] ) ? absint( $summary['rows_with_relative_path'] ) : 0;
3446 $total_rows = isset( $summary['total_rows'] ) ? absint( $summary['total_rows'] ) : 0;
3447
3448 if ( $total_rows <= 0 ) {
3449 return 'unknown';
3450 }
3451
3452 if ( $with_url > 0 && 0 === $with_path ) {
3453 return 'remote';
3454 }
3455
3456 if ( $with_path > 0 && 0 === $with_url ) {
3457 return 'local';
3458 }
3459
3460 if ( $with_url > 0 && $with_path > 0 ) {
3461 return 'mixed';
3462 }
3463
3464 return 'unknown';
3465 }
3466
3467 private function get_recognized_columns_for_preview( $header_map ) {
3468 $definitions = $this->get_import_header_definitions();
3469
3470 $recognized = [];
3471 foreach ( $definitions as $key => $definition ) {
3472 if ( isset( $header_map[ $key ] ) && ! empty( $definition['label'] ) ) {
3473 $recognized[] = (string) $definition['label'];
3474 }
3475 }
3476
3477 return $recognized;
3478 }
3479
3480 private function get_import_header_definitions() {
3481 $definitions = [
3482 'id' => [
3483 'aliases' => [ 'id', 'attachment id', 'media id', 'id del adjunto', 'id do anexo', 'id allegato', 'id de la pièce jointe' ],
3484 'label' => __( 'ID', 'calliope-media-import-export' ),
3485 ],
3486 'url' => [
3487 'aliases' => [ 'absolute url', 'url', 'absolute_url', 'source url', 'source_url', 'url absoluta', 'url absoluto', 'url absolue', 'url assoluto', '绝对 url' ],
3488 'label' => __( 'Absolute URL', 'calliope-media-import-export' ),
3489 ],
3490 'rel_path' => [
3491 'aliases' => [ 'relative path', 'relative_path', 'path', 'ruta relativa', 'caminho relativo', 'percorso relativo', 'chemin relatif', '相对路径' ],
3492 'label' => __( 'Relative Path', 'calliope-media-import-export' ),
3493 ],
3494 'title' => [
3495 'aliases' => [ 'title', 'post_title', 'título', 'titulo', 'titre', 'titolo', '标题' ],
3496 'label' => __( 'Title', 'calliope-media-import-export' ),
3497 ],
3498 'alt' => [
3499 'aliases' => [ 'alt text', 'alt', 'alternative text', 'texto alternativo', 'texto alt', 'texte alternatif', 'testo alternativo', '替代文本' ],
3500 'label' => __( 'Alt Text', 'calliope-media-import-export' ),
3501 ],
3502 'caption' => [
3503 'aliases' => [ 'caption', 'post_excerpt', 'subtítulo', 'subtitulo', 'legenda', 'légende', 'didascalia' ],
3504 'label' => __( 'Caption', 'calliope-media-import-export' ),
3505 ],
3506 'description' => [
3507 'aliases' => [ 'description', 'post_content', 'descripción', 'descripcion', 'descrição', 'descricao', 'descrizione', '描述' ],
3508 'label' => __( 'Description', 'calliope-media-import-export' ),
3509 ],
3510 ];
3511
3512 return apply_filters( 'eim_import_header_definitions', $definitions );
3513 }
3514
3515 private function validate_row_via_hooks( $row, $context ) {
3516 $validation = apply_filters( 'eim_validate_import_row', true, $row, $context );
3517
3518 if ( true === $validation || null === $validation ) {
3519 return true;
3520 }
3521
3522 if ( is_wp_error( $validation ) ) {
3523 return $validation;
3524 }
3525
3526 if ( false === $validation ) {
3527 return new WP_Error( 'eim_import_row_invalid', __( 'This row did not pass import validation.', 'calliope-media-import-export' ) );
3528 }
3529
3530 if ( is_string( $validation ) && '' !== trim( $validation ) ) {
3531 return new WP_Error( 'eim_import_row_invalid', trim( $validation ) );
3532 }
3533
3534 return true;
3535 }
3536
3537 private function build_csv_warnings( $summary, $header_map, $missing_row_index ) {
3538 $warnings = [];
3539
3540 if ( ! isset( $header_map['url'] ) && isset( $header_map['rel_path'] ) ) {
3541 $warnings[] = __( 'This CSV relies on Relative Path. Use Local Import Mode or make sure the referenced files already exist in uploads.', 'calliope-media-import-export' );
3542 }
3543
3544 if ( isset( $summary['rows_missing_source'] ) && absint( $summary['rows_missing_source'] ) > 0 ) {
3545 $count = absint( $summary['rows_missing_source'] );
3546 $rows = implode( ', ', array_map( 'absint', (array) $missing_row_index ) );
3547 /* translators: %s: comma-separated CSV row numbers. */
3548 $note = $rows ? sprintf( __( ' Example rows: %s.', 'calliope-media-import-export' ), $rows ) : '';
3549
3550 $warnings[] = sprintf(
3551 /* translators: %d: number of CSV rows missing source fields. */
3552 _n(
3553 '%d row is missing both Absolute URL and Relative Path and will fail unless the CSV is corrected.',
3554 '%d rows are missing both Absolute URL and Relative Path and will fail unless the CSV is corrected.',
3555 $count,
3556 'calliope-media-import-export'
3557 ),
3558 $count
3559 ) . $note;
3560 }
3561
3562 if ( ! isset( $header_map['title'] ) && ! isset( $header_map['alt'] ) && ! isset( $header_map['caption'] ) && ! isset( $header_map['description'] ) ) {
3563 $warnings[] = __( 'Only source columns were detected. Media metadata fields will not be updated from this CSV.', 'calliope-media-import-export' );
3564 }
3565
3566 return array_values( array_filter( $warnings ) );
3567 }
3568
3569 private function create_temp_import_file( $source_path, $inspection ) {
3570 $temp_dir = $this->ensure_temp_dir();
3571 if ( is_wp_error( $temp_dir ) ) {
3572 return $temp_dir;
3573 }
3574
3575 $token = str_replace( '-', '', wp_generate_uuid4() );
3576 $base_name = 'import-' . sanitize_key( $token );
3577 $csv_filename = $base_name . '.csv';
3578 $csv_path = trailingslashit( $temp_dir ) . $csv_filename;
3579 $meta_path = trailingslashit( $temp_dir ) . $base_name . '.meta.json';
3580
3581 if ( ! $this->copy_file_streaming( $source_path, $csv_path ) ) {
3582 return new WP_Error( 'eim_temp_copy_failed', __( 'Could not prepare the temporary CSV file.', 'calliope-media-import-export' ) );
3583 }
3584
3585 $meta = [
3586 'delimiter' => isset( $inspection['delimiter'] ) ? (string) $inspection['delimiter'] : ',',
3587 'total_rows' => isset( $inspection['total_rows'] ) ? absint( $inspection['total_rows'] ) : 0,
3588 'created_at' => time(),
3589 'created_by' => get_current_user_id(),
3590 ];
3591
3592 $encoded_meta = wp_json_encode( $meta );
3593 if ( false === $encoded_meta || false === @file_put_contents( $meta_path, $encoded_meta, LOCK_EX ) ) {
3594 wp_delete_file( $csv_path );
3595 return new WP_Error( 'eim_temp_meta_failed', __( 'Could not store temporary import metadata.', 'calliope-media-import-export' ) );
3596 }
3597
3598 $this->reset_import_progress_log( $csv_filename );
3599
3600 return [
3601 'file' => $csv_filename,
3602 ];
3603 }
3604
3605 private function ensure_temp_dir() {
3606 $temp_dir = $this->get_temp_dir();
3607
3608 if ( ! file_exists( $temp_dir ) && ! wp_mkdir_p( $temp_dir ) ) {
3609 return new WP_Error( 'eim_temp_dir_failed', __( 'Could not create the temporary import folder.', 'calliope-media-import-export' ) );
3610 }
3611
3612 if ( ! is_dir( $temp_dir ) || ! $this->is_path_writable( $temp_dir ) ) {
3613 return new WP_Error( 'eim_temp_dir_unwritable', __( 'The temporary import folder is not writable.', 'calliope-media-import-export' ) );
3614 }
3615
3616 $this->write_temp_dir_guards( $temp_dir );
3617
3618 return $temp_dir;
3619 }
3620
3621 private function write_temp_dir_guards( $temp_dir ) {
3622 $guards = [
3623 'index.php' => "<?php\n// Silence is golden.\n",
3624 '.htaccess' => "Deny from all\n",
3625 'web.config' => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n <system.webServer>\n <authorization>\n <deny users=\"*\" />\n </authorization>\n </system.webServer>\n</configuration>\n",
3626 ];
3627
3628 foreach ( $guards as $filename => $contents ) {
3629 $path = trailingslashit( $temp_dir ) . $filename;
3630 if ( ! file_exists( $path ) ) {
3631 @file_put_contents( $path, $contents, LOCK_EX );
3632 }
3633 }
3634 }
3635
3636 private function copy_file_streaming( $source_path, $destination_path ) {
3637 $source = $this->open_read_handle( $source_path );
3638 if ( ! $source ) {
3639 return false;
3640 }
3641
3642 $destination = $this->open_write_handle( $destination_path );
3643 if ( ! $destination ) {
3644 $this->close_file_handle( $source );
3645 return false;
3646 }
3647
3648 $copied = stream_copy_to_stream( $source, $destination );
3649
3650 $this->close_file_handle( $source );
3651 $this->close_file_handle( $destination );
3652
3653 return false !== $copied;
3654 }
3655
3656 private function get_temp_file_paths( $file_name ) {
3657 $file_name = sanitize_file_name( (string) $file_name );
3658
3659 if ( '' === $file_name || ! preg_match( '/^import-[a-z0-9]+\.csv$/', $file_name ) ) {
3660 return new WP_Error( 'eim_temp_name_invalid', __( 'Invalid temporary file name.', 'calliope-media-import-export' ) );
3661 }
3662
3663 $temp_dir = $this->get_temp_dir();
3664 $meta_name = str_replace( '.csv', '.meta.json', $file_name );
3665
3666 return [
3667 'csv' => trailingslashit( $temp_dir ) . $file_name,
3668 'meta' => trailingslashit( $temp_dir ) . $meta_name,
3669 'progress' => trailingslashit( $temp_dir ) . str_replace( '.csv', '.progress.jsonl', $file_name ),
3670 ];
3671 }
3672
3673 private function read_temp_file_meta( $file_name ) {
3674 $paths = $this->get_temp_file_paths( $file_name );
3675 if ( is_wp_error( $paths ) ) {
3676 return $paths;
3677 }
3678
3679 if ( ! file_exists( $paths['meta'] ) ) {
3680 if ( file_exists( $paths['csv'] ) ) {
3681 return $this->rebuild_temp_file_meta( $paths['csv'], $paths['meta'] );
3682 }
3683
3684 return new WP_Error( 'eim_temp_meta_missing', __( 'Temporary import metadata not found. Please upload the CSV again.', 'calliope-media-import-export' ) );
3685 }
3686
3687 $raw_meta = @file_get_contents( $paths['meta'] );
3688 if ( false === $raw_meta || '' === trim( (string) $raw_meta ) ) {
3689 return $this->rebuild_temp_file_meta( $paths['csv'], $paths['meta'] );
3690 }
3691
3692 $meta = json_decode( $raw_meta, true );
3693 if ( ! is_array( $meta ) ) {
3694 return $this->rebuild_temp_file_meta( $paths['csv'], $paths['meta'] );
3695 }
3696
3697 return $meta;
3698 }
3699
3700 private function rebuild_temp_file_meta( $csv_path, $meta_path ) {
3701 if ( ! file_exists( $csv_path ) ) {
3702 return new WP_Error( 'eim_temp_meta_invalid', __( 'Temporary import metadata is invalid. Please upload the CSV again.', 'calliope-media-import-export' ) );
3703 }
3704
3705 $inspection = $this->inspect_csv_file( $csv_path );
3706 if ( is_wp_error( $inspection ) ) {
3707 return new WP_Error( 'eim_temp_meta_invalid', __( 'Temporary import metadata is invalid. Please upload the CSV again.', 'calliope-media-import-export' ) );
3708 }
3709
3710 $meta = [
3711 'delimiter' => isset( $inspection['delimiter'] ) ? (string) $inspection['delimiter'] : ',',
3712 'total_rows' => isset( $inspection['total_rows'] ) ? absint( $inspection['total_rows'] ) : 0,
3713 'created_at' => time(),
3714 'created_by' => get_current_user_id(),
3715 ];
3716
3717 $encoded_meta = wp_json_encode( $meta );
3718 if ( false !== $encoded_meta ) {
3719 @file_put_contents( $meta_path, $encoded_meta, LOCK_EX );
3720 }
3721
3722 return $meta;
3723 }
3724
3725 private function cleanup_temp_import_file( $file_name ) {
3726 $paths = $this->get_temp_file_paths( $file_name );
3727 if ( is_wp_error( $paths ) ) {
3728 return;
3729 }
3730
3731 if ( file_exists( $paths['csv'] ) ) {
3732 wp_delete_file( $paths['csv'] );
3733 }
3734
3735 if ( file_exists( $paths['meta'] ) ) {
3736 wp_delete_file( $paths['meta'] );
3737 }
3738
3739 if ( file_exists( $paths['progress'] ) ) {
3740 wp_delete_file( $paths['progress'] );
3741 }
3742 }
3743
3744 private function reset_import_progress_log( $file_name ) {
3745 $paths = $this->get_temp_file_paths( $file_name );
3746 if ( is_wp_error( $paths ) ) {
3747 return;
3748 }
3749
3750 if ( file_exists( $paths['progress'] ) ) {
3751 wp_delete_file( $paths['progress'] );
3752 }
3753
3754 @file_put_contents( $paths['progress'], '', LOCK_EX );
3755 }
3756
3757 private function append_import_progress( $file_name, $result ) {
3758 $paths = $this->get_temp_file_paths( $file_name );
3759 if ( is_wp_error( $paths ) || ! is_array( $result ) ) {
3760 return;
3761 }
3762
3763 $cursor = isset( $result['row_number'] ) ? absint( $result['row_number'] ) : 0;
3764 if ( $cursor <= 0 ) {
3765 return;
3766 }
3767
3768 $entry = [
3769 'cursor' => $cursor,
3770 'created_at' => time(),
3771 'result' => $result,
3772 ];
3773
3774 $encoded = wp_json_encode( $entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
3775 if ( false === $encoded ) {
3776 return;
3777 }
3778
3779 @file_put_contents( $paths['progress'], $encoded . "\n", FILE_APPEND | LOCK_EX );
3780 }
3781
3782 private function get_temp_dir() {
3783 $upload_dir = wp_upload_dir();
3784 return trailingslashit( $upload_dir['basedir'] ) . 'eim-temp/';
3785 }
3786
3787 private function get_temp_lock_key( $file_name ) {
3788 return 'eim_import_lock_' . md5( (string) $file_name );
3789 }
3790
3791 private function acquire_temp_lock( $lock_key, $ttl = null ) {
3792 $ttl = null === $ttl ? self::LOCK_TTL : max( 30, absint( $ttl ) );
3793 $existing = get_transient( $lock_key );
3794
3795 if ( $existing ) {
3796 $started_at = is_array( $existing ) && isset( $existing['started_at'] ) ? absint( $existing['started_at'] ) : absint( $existing );
3797 $age = $started_at > 0 ? time() - $started_at : 0;
3798
3799 if ( $started_at > 0 && $age > $ttl ) {
3800 delete_transient( $lock_key );
3801 } else {
3802 return false;
3803 }
3804 }
3805
3806 return set_transient(
3807 $lock_key,
3808 [
3809 'started_at' => time(),
3810 ],
3811 $ttl
3812 );
3813 }
3814
3815 private function release_temp_lock( $lock_key ) {
3816 delete_transient( $lock_key );
3817 }
3818
3819 private function validate_existing_media_file( $file_path ) {
3820 $filename = wp_basename( $file_path );
3821 if ( $this->is_svg_import_file( $file_path, $filename ) ) {
3822 $svg_validation = $this->maybe_validate_svg_import_file( $file_path, $filename );
3823 if ( is_wp_error( $svg_validation ) ) {
3824 return $svg_validation;
3825 }
3826
3827 return [
3828 'mime' => 'image/svg+xml',
3829 'ext' => 'svg',
3830 'filename' => sanitize_file_name( $filename ),
3831 ];
3832 }
3833
3834 $allowed_mimes = apply_filters( 'eim_allowed_local_mimes', $this->get_import_allowed_mimes() );
3835 $filetype = wp_check_filetype_and_ext( $file_path, $filename, $allowed_mimes );
3836 $mime = ! empty( $filetype['type'] ) ? (string) $filetype['type'] : '';
3837 $ext = ! empty( $filetype['ext'] ) ? (string) $filetype['ext'] : '';
3838 $major_type = strtok( $mime, '/' );
3839
3840 if ( '' === $mime || '' === $ext ) {
3841 return new WP_Error( 'eim_local_type_invalid', __( 'Local file type is not allowed.', 'calliope-media-import-export' ) );
3842 }
3843
3844 if ( ! in_array( $major_type, [ 'image', 'video', 'audio', 'application' ], true ) ) {
3845 return new WP_Error( 'eim_local_type_invalid', __( 'Local file type is not supported by this plugin.', 'calliope-media-import-export' ) );
3846 }
3847
3848 if ( ! empty( $filetype['proper_filename'] ) ) {
3849 $filename = $filetype['proper_filename'];
3850 }
3851
3852 return [
3853 'mime' => $mime,
3854 'ext' => $ext,
3855 'filename' => sanitize_file_name( $filename ),
3856 ];
3857 }
3858
3859 private function derive_filename( $url, $rel_path = '' ) {
3860 $candidate = '';
3861
3862 if ( '' !== $rel_path ) {
3863 $candidate = wp_basename( $rel_path );
3864 } elseif ( '' !== $url ) {
3865 $path = wp_parse_url( $url, PHP_URL_PATH );
3866 if ( is_string( $path ) && '' !== $path ) {
3867 $candidate = wp_basename( $path );
3868 }
3869 }
3870
3871 $candidate = urldecode( (string) $candidate );
3872 $candidate = sanitize_file_name( $candidate );
3873
3874 return '' !== $candidate ? $candidate : 'media-file';
3875 }
3876
3877 private function build_import_action_context( $request_context, $row = [], $extra = [] ) {
3878 $request_context = $this->get_result_request_context( $request_context );
3879 $extra = is_array( $extra ) ? $extra : [];
3880
3881 return array_merge(
3882 [
3883 'request_context' => $request_context,
3884 'row' => is_array( $row ) ? $row : [],
3885 'dry_run' => ! empty( $request_context['dry_run'] ),
3886 'pro_history_id' => isset( $request_context['pro_history_id'] ) ? absint( $request_context['pro_history_id'] ) : 0,
3887 'pro_job_id' => isset( $request_context['pro_job_id'] ) ? absint( $request_context['pro_job_id'] ) : 0,
3888 ],
3889 $extra
3890 );
3891 }
3892
3893 private function get_result_request_context( $request_context ) {
3894 $request_context = is_array( $request_context ) ? $request_context : [];
3895
3896 return [
3897 'source' => isset( $request_context['source'] ) ? sanitize_key( (string) $request_context['source'] ) : 'runtime',
3898 'file' => isset( $request_context['file'] ) ? sanitize_file_name( (string) $request_context['file'] ) : '',
3899 'dry_run' => ! empty( $request_context['dry_run'] ),
3900 'local_import' => ! empty( $request_context['local_import'] ),
3901 'skip_thumbnails' => ! empty( $request_context['skip_thumbnails'] ),
3902 'honor_relative_path' => ! isset( $request_context['honor_relative_path'] ) || ! empty( $request_context['honor_relative_path'] ),
3903 'duplicate_strategy' => isset( $request_context['duplicate_strategy'] ) ? sanitize_key( (string) $request_context['duplicate_strategy'] ) : 'skip',
3904 'match_strategy' => isset( $request_context['match_strategy'] ) ? sanitize_key( (string) $request_context['match_strategy'] ) : 'auto',
3905 'selected_update_fields' => isset( $request_context['selected_update_fields'] ) ? $this->normalize_selected_update_fields( $request_context['selected_update_fields'] ) : [],
3906 'advanced_import_actions_allowed' => ! empty( $request_context['advanced_import_actions_allowed'] ),
3907 'pro_history_id' => ! empty( $request_context['advanced_import_actions_allowed'] ) && isset( $request_context['pro_history_id'] ) ? absint( $request_context['pro_history_id'] ) : 0,
3908 'pro_job_id' => ! empty( $request_context['advanced_import_actions_allowed'] ) && isset( $request_context['pro_job_id'] ) ? absint( $request_context['pro_job_id'] ) : 0,
3909 'convert_images_format' => ! empty( $request_context['advanced_import_actions_allowed'] ) && isset( $request_context['convert_images_format'] ) ? sanitize_key( (string) $request_context['convert_images_format'] ) : 'keep',
3910 'conversion_quality' => isset( $request_context['conversion_quality'] ) ? min( 100, max( 1, absint( $request_context['conversion_quality'] ) ) ) : 82,
3911 'conversion_failure_behavior' => ! empty( $request_context['advanced_import_actions_allowed'] ) && isset( $request_context['conversion_failure_behavior'] ) ? sanitize_key( (string) $request_context['conversion_failure_behavior'] ) : 'keep_original',
3912 ];
3913 }
3914
3915 private function build_item_result( $status, $file, $message, $context = [] ) {
3916 $result = [
3917 'status' => (string) $status,
3918 'file' => (string) $file,
3919 'message' => (string) $message,
3920 ];
3921
3922 if ( ! empty( $context ) && is_array( $context ) ) {
3923 $result['context'] = $context;
3924 }
3925
3926 return apply_filters( 'eim_import_item_result', $result, $context, $status );
3927 }
3928
3929
3930 private function open_read_handle( $file_path ) {
3931 // Help PHP read legacy CSV files that use old Mac-style CR line endings.
3932 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort compatibility setting before opening a stream.
3933 @ini_set( 'auto_detect_line_endings', '1' );
3934
3935 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming CSV and binary files for import processing.
3936 return @fopen( $file_path, 'rb' );
3937 }
3938
3939 private function open_write_handle( $file_path ) {
3940 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming CSV and temporary files for import processing.
3941 return @fopen( $file_path, 'wb' );
3942 }
3943
3944 private function close_file_handle( $handle ) {
3945 if ( is_resource( $handle ) ) {
3946 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing an already opened stream handle.
3947 fclose( $handle );
3948 }
3949 }
3950
3951 private function read_file_chunk( $handle, $length ) {
3952 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reading a small binary chunk from an already opened stream handle.
3953 return fread( $handle, $length );
3954 }
3955
3956 private function is_path_writable( $path ) {
3957 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Temp directory validation before creating import files.
3958 return is_writable( $path );
3959 }
3960
3961 public function cleanup_temp_files() {
3962 $temp_dir = $this->get_temp_dir();
3963 if ( ! is_dir( $temp_dir ) ) {
3964 return;
3965 }
3966
3967 $files = @scandir( $temp_dir );
3968 if ( ! is_array( $files ) ) {
3969 return;
3970 }
3971
3972 $cutoff = time() - self::TEMP_FILE_TTL;
3973 foreach ( $files as $file ) {
3974 if ( in_array( $file, [ '.', '..', 'index.php', '.htaccess', 'web.config' ], true ) ) {
3975 continue;
3976 }
3977
3978 $file_path = trailingslashit( $temp_dir ) . $file;
3979 if ( ! is_file( $file_path ) ) {
3980 continue;
3981 }
3982
3983 $last_modified = @filemtime( $file_path );
3984 if ( false !== $last_modified && $last_modified < $cutoff ) {
3985 wp_delete_file( $file_path );
3986 }
3987 }
3988 }
3989 }
3990