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 |