diff --git a/.gitignore b/.gitignore index 93e43974..62fbbb91 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ build .DS_Store cc-test-reporter assets/build -test-results \ No newline at end of file +test-results +tests/assets/filestash \ No newline at end of file diff --git a/assets/css/single-attachment.css b/assets/css/single-attachment.css new file mode 100644 index 00000000..ca7e902e --- /dev/null +++ b/assets/css/single-attachment.css @@ -0,0 +1,241 @@ +table.compat-attachment-fields { + border-collapse: collapse; + width: 100%; +} + +[class^="compat-field-optml_"] { + background: #fff; + border-collapse: separate; + border-spacing: 0; + border: 1px solid #ccc; +} + +[class^="compat-field-optml_"] tr { + background: #fff; +} + +[class^="compat-field-optml_"] th { + display: block; + float: none; + white-space: nowrap; +} + +[class^="compat-field-optml_"] th, +[class^="compat-field-optml_"] td { + padding: 20px; +} + +.compat-field-optml_footer_row { + padding: 0 !important; + background: #eee; +} + +.compat-field-optml_spacer_row { + height: 40px !important; + background: transparent !important; + border: 0 !important; +} + +.compat-field-optml_spacer_row td, +.compat-field-optml_spacer_row th { + padding: 0 !important; +} + +.compat-field-optml_footer_row th, +.compat-field-optml_footer_row td { + padding: 5px 20px; +} + + +.optml-logo-contianer { + justify-content: flex-end; + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + position:relative; +} + +.optml-logo-contianer img { + width: 25px; + height: 25px; +} + +.optml-rename-input:focus-within { + box-shadow: 0 0 0 1px #577BF9; +} + +.optml-rename-media-container { + display: flex; + gap: 10px; + align-items: center; +} + +.optml-rename-input { + display: flex; + align-items: stretch; + border-radius: 3px; + border: 1px solid #577BF9; + overflow: hidden; + background: #fff; + flex-grow: 1; +} + +.optml-rename-input #optml_rename_file { + border: 0; + border-radius: 0; + flex-grow: 1; + box-shadow: none; + background: transparent; + min-height: 30px; +} + +.optml-rename-input .optml-file-ext { + padding: 0 10px; + display: flex; + align-items: center; + font-weight: 600; + background-color: #e6effd; + border-left: 1px solid #577BF9; + color: #577BF9; +} + + +.optml-replace-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.optml-description { + color: #666; + margin: 0; + font-style: italic; +} + +.optml-replace-input { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; +} + +.optml-replace-input label { + width: 100%; + min-height: 100px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + flex-grow: 1; + padding: 8px; + border: 1px dashed #577BF9; + border-radius: 3px; + background: #fff; + transition: all 0.3s ease; +} + +.optml-replace-input label:hover { + background: #577BF9; + color: #fff; +} + +.optml-replace-file-preview { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; + font-weight: 600; +} + +.optml-replace-file-preview img { + object-fit: cover; + border-radius: 6px; + max-width: 250px; + max-height: 75px; + border: 1px solid #ccc; + background: #f0f0f0; +} + +.optml-replace-file-error { + color: rgb(163, 11, 0); + padding: 5px 10px; + border-radius: 3px; + border: 1px solid rgb(163, 11, 0); + background:rgb(255, 205, 201); +} + +#optml-file-drop-area { + box-sizing: border-box; + position: relative; + transition: all 0.3s ease; +} + +#optml-file-drop-area.drag-active { + background-color: #e6effd; + border: 1px dashed #577BF9; +} + +.optml-replace-file-actions { + display: flex; + align-items: center; + flex-direction: row-reverse; + justify-content: flex-end; + gap: 10px; + width: 100%; +} + +.optml-rename-actions { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + margin-top: 10px; +} + +.optml-replace-file-actions p { + margin-right: auto; +} + +[class^="compat-field-optml_"] .optml-btn { + border-radius: 3px !important; + border: 0 !important; + cursor: pointer; + transition: all 0.3s ease; + color: #fff !important; + margin-bottom: 0 !important; +} + +[class^="compat-field-optml_"] .optml-btn.primary { + background: #577BF9 !important; +} + +[class^="compat-field-optml_"] .optml-btn.destructive { + background: #D93025 !important; +} + +[class^="compat-field-optml_"] .optml-btn:disabled { + opacity: 0.5; + cursor: not-allowed !important; + pointer-events: none; + color: #fff !important; +} + +.optml-btn.primary:hover { + background: #4161d7; +} + +.optml-btn.destructive:hover { + background: #c2291e; +} + +.optml-svg-loader { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/assets/js/single-attachment.js b/assets/js/single-attachment.js new file mode 100644 index 00000000..f2fc6825 --- /dev/null +++ b/assets/js/single-attachment.js @@ -0,0 +1,163 @@ + +jQuery(document).ready(function($) { + const existingFileName = $("#optml_rename_file").attr("placeholder"); + const renameBtn = $("#optml-rename-file-btn"); + + renameBtn.on("click", function(e) { + e.preventDefault(); + $('#publish').click(); + }); + + $("#optml_rename_file").on("input", function(e) { + const newFileName = $(this).val(); + if (newFileName === existingFileName) { + return; + } + + if(newFileName.trim().length === 0 || newFileName.trim() .length > 100) { + renameBtn.prop("disabled", true); + return; + } + + renameBtn.prop("disabled", false); + }); + + $("#optml-replace-file-field").on("change", function(e) { + handleFileSelect(this.files[0]); + }); + + $("#optml-replace-file-btn").on("click", function(e) { + e.preventDefault(); + uploadFile(); + }); + + $("#optml-replace-clear-btn").on("click", function(e) { + e.preventDefault(); + resetFileReplacer(); + }); + + const dropArea = document.getElementById("optml-file-drop-area"); + + ["dragenter", "dragover", "dragleave", "drop"].forEach(event => { + dropArea.addEventListener(event, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ["dragenter", "dragover"].forEach(event => { + dropArea.addEventListener(event, highlight, false); + }); + + ["dragleave", "drop"].forEach(event => { + dropArea.addEventListener(event, unhighlight, false); + }); + + function highlight() { + dropArea.classList.add("drag-active"); + } + + function unhighlight() { + dropArea.classList.remove("drag-active"); + } + + dropArea.addEventListener("drop", handleDrop, false); + + function handleDrop(e) { + const dt = e.dataTransfer; + const file = dt.files[0]; + handleFileSelect(file); + } + + function resetFileReplacer(error = null) { + if( error ) { + $(".optml-replace-file-error").removeClass("hidden"); + $(".optml-replace-file-error").text(error); + } else { + $(".optml-replace-file-error").addClass("hidden"); + } + + $("#optml-replace-file-btn").prop("disabled", true); + $("#optml-replace-file-field").val(""); + $(".optml-replace-file-preview").html(""); + $(".label-text").show(); + } + + function handleFileSelect(file) { + $(".optml-replace-file-error").addClass("hidden"); + + if(!file) return; + + if(OMAttachmentEdit.mimeType !== file.type) { + resetFileReplacer(OMAttachmentEdit.i18n.mimeTypeError); + return; + } + + // Check file size + if(file.size > OMAttachmentEdit.maxFileSize) { + resetFileReplacer(OMAttachmentEdit.i18n.maxFileSizeError); + + return; + } + + // Set the file in the input + if (file instanceof File) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + document.getElementById("optml-replace-file-field").files = dataTransfer.files; + } + + // Enable button and update UI + $("#optml-replace-file-btn").prop("disabled", false); + $("#optml-replace-clear-btn").prop("disabled", false); + $(".label-text").hide(); + + const type = file.type; + let showPreview = type.startsWith("image/"); + let html = showPreview ? "<img src='" + URL.createObjectURL(file) + "' />" : ""; + html += "<span>" + file.name + "</span>"; + + $(".optml-replace-file-preview").html(html); + } + + function uploadFile() { + var formData = new FormData(); + formData.append("action", "optml_replace_file"); + formData.append("attachment_id", OMAttachmentEdit.attachmentId); + formData.append("file", $("#optml-replace-file-field")[0].files[0]); + + $(".optml-svg-loader").show(); + + jQuery.ajax({ + url: OMAttachmentEdit.ajaxURL, + type: "POST", + data: formData, + processData: false, + contentType: false, + success: function(response) { + $(".optml-svg-loader").hide(); + if(response.success) { + window.location.reload(); + } else { + $(".optml-replace-file-error").removeClass("hidden"); + $(".optml-replace-file-error").text(response.message); + } + }, + error: function(response) { + $(".optml-svg-loader").hide(); + resetFileReplacer(response.message || OMAttachmentEdit.i18n.replaceFileError); + } + }); + } + + function clearFile() { + resetFileReplacer(); + // $(".optml-replace-file-preview").html(""); + // $(".label-text").show(); + // $("#optml-replace-file-btn").prop("disabled", true); + // $("#optml-replace-clear-btn").prop("disabled", true); + // $("#optml-replace-file-field").val(""); + } +}); \ No newline at end of file diff --git a/inc/admin.php b/inc/admin.php index 2ebcc27d..f34bec74 100755 --- a/inc/admin.php +++ b/inc/admin.php @@ -52,6 +52,9 @@ public function __construct() { $this->settings = new Optml_Settings(); $this->conflicting_plugins = new Optml_Conflicting_Plugins(); + $media_rename = new Optml_Attachment_Edit(); + $media_rename->init(); + $dashboard_widget = new Optml_Dashboard_Widget(); $dashboard_widget->init(); diff --git a/inc/media_rename/attachment_db_renamer.php b/inc/media_rename/attachment_db_renamer.php new file mode 100644 index 00000000..8dc60193 --- /dev/null +++ b/inc/media_rename/attachment_db_renamer.php @@ -0,0 +1,612 @@ +<?php +/** + * Attachment db renamer class. + */ + +/** + * URL Replacer for WordPress + * + * A standalone class to replace URLs in WordPress database, + * including handling image size variations and scaled images. + * + * @since 4.0.0 + */ +class Optml_Attachment_Db_Renamer { + /** + * Tables to skip during replacement + * + * @var array + */ + private $skip_tables = [ 'users', 'terms', 'term_relationships', 'term_taxonomy' ]; + + /** + * Columns to skip during replacement + * + * @var array + */ + private $skip_columns = [ 'user_pass' ]; + + /** + * Handle image size variations + * + * @var bool + */ + private $handle_image_sizes = false; + + /** + * Constructor + */ + public function __construct( $skip_sizes = false ) { + $this->handle_image_sizes = ! $skip_sizes; + } + + /** + * Replace URLs in the WordPress database + * + * @param string $old_url The base URL to search for (e.g., http://domain.com/wp-content/uploads/2025/03/image.jpg). + * @param string $new_url The base URL to replace with (e.g., http://domain.com/wp-content/uploads/2025/03/new-name.jpg). + * + * @return int Number of replacements made + */ + public function replace( $old_url, $new_url ) { + if ( $old_url === $new_url ) { + return 0; + } + + if ( empty( $old_url ) || empty( $new_url ) ) { + return 0; + } + + if ( ! is_string( $old_url ) || ! is_string( $new_url ) ) { // @phpstan-ignore-line docs require a string but it could be empty + return 0; + } + + $tables = $this->get_tables(); + $total_replacements = 0; + + foreach ( $tables as $table ) { + if ( in_array( $table, $this->skip_tables, true ) ) { + continue; + } + + list($primary_keys, $columns) = $this->get_columns( $table ); + + // Skip tables with no primary keys + if ( empty( $primary_keys ) ) { + continue; + } + + foreach ( $columns as $column ) { + if ( in_array( $column, $this->skip_columns, true ) ) { + continue; + } + + $replacements = $this->process_column( $table, $column, $primary_keys, $old_url, $new_url ); + $total_replacements += $replacements; + } + } + + return $total_replacements; + } + + /** + * Get WordPress tables + * + * @return array Table names + */ + private function get_tables() { + global $wpdb; + + return array_values( $wpdb->tables() ); + } + + /** + * Get columns for a table + * + * @param string $table Table name. + * + * @return array Array containing primary keys and text columns + */ + private function get_columns( $table ) { + global $wpdb; + + $primary_keys = []; + $text_columns = []; + + // Get table information + $results = $wpdb->get_results( $wpdb->prepare( 'DESCRIBE %i', $table ) ); + + if ( ! empty( $results ) ) { + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + foreach ( $results as $col ) { + if ( 'PRI' === $col->Key ) { + $primary_keys[] = $col->Field; + } + if ( $this->is_text_col( $col->Type ) ) { + $text_columns[] = $col->Field; + } + } + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + + return [ $primary_keys, $text_columns ]; + } + + /** + * Check if column is text type + * + * @param string $type Column type. + * + * @return bool True if text column + */ + private function is_text_col( $type ) { + foreach ( [ 'text', 'varchar', 'longtext', 'mediumtext', 'char' ] as $token ) { + if ( false !== stripos( $type, $token ) ) { + return true; + } + } + return false; + } + + /** + * Process a single column for replacements + * + * @param string $table Table name. + * @param string $column Column name. + * @param array $primary_keys Primary keys. + * @param string $old_url Old URL. + * @param string $new_url New URL. + * + * @return int Number of replacements + */ + private function process_column( $table, $column, $primary_keys, $old_url, $new_url ) { + global $wpdb; + + $count = 0; + + // Check for serialized data + $has_serialized = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(%i) FROM %i WHERE %i REGEXP '^[aiO]:[1-9]' LIMIT 1", + $column, + $table, + $column + ) + ); + + // Process with PHP if serialized data is found + if ( $has_serialized ) { + $count = $this->php_handle_column( $table, $column, $primary_keys, $old_url, $new_url ); + } else { + // Use direct SQL replacement for non-serialized data + $count = $this->sql_handle_column( $table, $column, $old_url, $new_url ); + } + + return $count; + } + + /** + * Handle column using SQL replacement + * + * @param string $table Table name. + * @param string $column Column name. + * @param string $old_url Old URL. + * @param string $new_url New URL. + * + * @return int Number of replacements + */ + private function sql_handle_column( $table, $column, $old_url, $new_url ) { + global $wpdb; + $count = 0; + + // Get the filename components + $old_path_parts = parse_url( $old_url ); + if ( ! isset( $old_path_parts['path'] ) ) { + return 0; + } + + $old_path = $old_path_parts['path']; + $old_file_info = pathinfo( $old_path ); + + $old_base = $old_file_info['filename']; + $old_dir = dirname( $old_path ); + $old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : ''; + + // Build pattern to match any URL containing the base filename + $base_url = $old_domain . $old_dir . '/' . $old_base; + + // Get rows with regular URLs + $rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM %i WHERE %i LIKE %s', + $table, + $column, + '%' . $wpdb->esc_like( $base_url ) . '%' + ) + ); + + // Also create a pattern for JSON-escaped version + $json_base_url = str_replace( '/', '\/', $base_url ); + + // Get rows with JSON-escaped URLs + $json_rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM %i WHERE %i LIKE %s', + $table, + $column, + '%' . $wpdb->esc_like( $json_base_url ) . '%' + ) + ); + + // Merge results, avoiding duplicates + $processed_ids = []; + $all_rows = array_merge( $rows, $json_rows ); + + if ( empty( $all_rows ) ) { + return 0; + } + + foreach ( $all_rows as $row ) { + $id_field = $row->ID ?? $row->id ?? null; + if ( ! $id_field ) { + foreach ( $row as $field => $value ) { + if ( stripos( $field, 'id' ) !== false ) { + $id_field = $value; + break; + } + } + } + + if ( ! $id_field ) { + continue; + } + + // Skip if we've already processed this row + if ( isset( $processed_ids[ $id_field ] ) ) { + continue; + } + $processed_ids[ $id_field ] = true; + + $content = $row->$column; + $new_content = $this->replace_image_urls( $content, $old_url, $new_url ); + + if ( $content !== $new_content ) { + $wpdb->update( + $table, + [ $column => $new_content ], + [ 'ID' => $id_field ] + ); + ++$count; + } + } + + return $count; + } + + /** + * Handle column using PHP for serialized data + * + * @param string $table Table name. + * @param string $column Column name. + * @param array $primary_keys Primary keys. + * @param string $old_url Old URL. + * @param string $new_url New URL. + * + * @return int Number of replacements + */ + private function php_handle_column( $table, $column, $primary_keys, $old_url, $new_url ) { + global $wpdb; + + $count = 0; + $json_old_url = str_replace( '/', '\/', $old_url ); + + // Build the query and allow processing with multiple primary keys. + $query = 'SELECT '; + foreach ( $primary_keys as $key => $value ) { + $query .= $wpdb->prepare( '%i, ', $value ); + } + $query .= '%i FROM %i WHERE %s LIKE %s LIMIT 100'; + + // Get the rows that need updating - first for regular URLs + $rows = $wpdb->get_results( + $wpdb->prepare( + $query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $column, + $table, + $column, + '%' . $wpdb->esc_like( $old_url ) . '%' + ) + ); + + // Also get rows with JSON-escaped URLs + $json_rows = $wpdb->get_results( + $wpdb->prepare( + $query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $column, + $table, + $column, + '%' . $wpdb->esc_like( $json_old_url ) . '%' + ) + ); + + // Merge results, avoiding duplicates + $processed_ids = []; + $all_rows = array_merge( $rows, $json_rows ); + + foreach ( $all_rows as $row ) { + // Generate a unique identifier for this row based on primary keys + $row_id = ''; + foreach ( $primary_keys as $key ) { + $row_id .= $row->$key . '|'; + } + + // Skip if we've already processed this row + if ( isset( $processed_ids[ $row_id ] ) ) { + continue; + } + + $processed_ids[ $row_id ] = true; + $value = $row->$column; + + // Skip empty values + if ( empty( $value ) ) { + continue; + } + + // Replace URLs in the value (handling serialized data) + $new_value = $this->replace_urls_in_value( $value, $old_url, $new_url ); + + // Skip if no change + if ( $value === $new_value ) { + continue; + } + + // Build WHERE clause for this row + $where_conditions = []; + foreach ( $primary_keys as $key ) { + $where_conditions[ $key ] = $row->$key; + } + + // Update the row + $updated = $wpdb->update( + $table, + [ $column => $new_value ], + $where_conditions + ); + + if ( $updated ) { + ++$count; + } + } + + return $count; + } + + /** + * Replace URLs in a value, handling serialized data + * + * @param string $value The value to process. + * @param string $old_url Old URL. + * @param string $new_url New URL. + * + * @return string The processed value + */ + private function replace_urls_in_value( $value, $old_url, $new_url ) { + // Check if the value is serialized + if ( $this->is_serialized( $value ) ) { + $unserialized = @unserialize( $value ); + + // If unserialize successful, process the data + if ( $unserialized !== false ) { + $replaced = $this->replace_in_data( $unserialized, $old_url, $new_url ); + return serialize( $replaced ); + } + } + + // Handle image sizes for non-serialized content + if ( $this->handle_image_sizes ) { + return $this->replace_image_urls( $value, $old_url, $new_url ); + } + + // Simple string replacement for non-serialized data + return str_replace( $old_url, $new_url, $value ); + } + + /** + * Replace image URLs including various WordPress size variations and scaled images + * + * @param string $content The content to process. + * @param string $old_url Old URL pattern. + * @param string $new_url New URL pattern. + * + * @return string The processed content + */ + private function replace_image_urls( $content, $old_url, $new_url ) { + // Get the filename components + $old_path_parts = parse_url( $old_url ); + $new_path_parts = parse_url( $new_url ); + + if ( ! isset( $old_path_parts['path'] ) || ! isset( $new_path_parts['path'] ) ) { + // If we can't parse the URLs, fallback to direct replacement + return str_replace( $old_url, $new_url, $content ); + } + + // Extract file name info + $old_path = $old_path_parts['path']; + $new_path = $new_path_parts['path']; + + $old_file_info = pathinfo( $old_path ); + $new_file_info = pathinfo( $new_path ); + + $old_base = $old_file_info['filename']; + $new_base = $new_file_info['filename']; + $old_ext = isset( $old_file_info['extension'] ) ? $old_file_info['extension'] : ''; + $new_ext = isset( $new_file_info['extension'] ) ? $new_file_info['extension'] : $old_ext; + + // Define domain parts + $old_domain = isset( $old_path_parts['host'] ) ? 'http' . ( isset( $old_path_parts['scheme'] ) && $old_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $old_path_parts['host'] : ''; + $new_domain = isset( $new_path_parts['host'] ) ? 'http' . ( isset( $new_path_parts['scheme'] ) && $new_path_parts['scheme'] === 'https' ? 's' : '' ) . '://' . $new_path_parts['host'] : ''; + + // Replace original URLs + $content = str_replace( $old_url, $new_url, $content ); + + // Replace JSON-escaped URLs + $json_old_url = str_replace( '/', '\/', $old_url ); + $json_new_url = str_replace( '/', '\/', $new_url ); + $content = str_replace( $json_old_url, $json_new_url, $content ); + + // If we have a file with extension, handle variations + if ( ! empty( $old_ext ) && $this->handle_image_sizes ) { + $old_dir = dirname( $old_path ); + $new_dir = dirname( $new_path ); + + // Replace WordPress image size variations (e.g., image-300x200.jpg) + $size_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/'; + + $content = preg_replace_callback( + $size_pattern, + function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) { + // Extract the size part (e.g., -300x200) + $size_part = substr( $matches[0], strlen( $old_domain . $old_dir . '/' . $old_base ), -strlen( '.' . $old_ext ) ); + + // Build the new URL with the same size + return $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext; + }, + $content + ); + + // Replace -scaled variations + $scaled_pattern = '/' . preg_quote( $old_domain . $old_dir . '/' . $old_base, '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/'; + + $content = preg_replace_callback( + $scaled_pattern, + function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) { + return $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext; + }, + $content + ); + + // Replace JSON-escaped variations + $json_size_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-\d+x\d+\.' . preg_quote( $old_ext, '/' ) . '/'; + + $content = preg_replace_callback( + $json_size_pattern, + function ( $matches ) use ( $old_base, $new_base, $old_domain, $new_domain, $old_dir, $new_dir, $old_ext, $new_ext ) { + // Extract the size part (e.g., -300x200) + $size_part = substr( $matches[0], strlen( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ) ), -strlen( '.' . $old_ext ) ); + + // Build the new URL with the same size + return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . $size_part . '.' . $new_ext ); + }, + $content + ); + + // Replace JSON-escaped scaled variations + $json_scaled_pattern = '/' . preg_quote( str_replace( '/', '\/', $old_domain . $old_dir . '/' . $old_base ), '/' ) . '-scaled\.' . preg_quote( $old_ext, '/' ) . '/'; + + $content = preg_replace_callback( + $json_scaled_pattern, + function ( $matches ) use ( $new_base, $new_domain, $new_dir, $new_ext ) { + return str_replace( '/', '\/', $new_domain . $new_dir . '/' . $new_base . '-scaled.' . $new_ext ); + }, + $content + ); + } + + return $content; + } + + /** + * Recursively replace URLs in data structure + * + * @param mixed $data The data to process. + * @param string $old_url Old URL. + * @param string $new_url New URL. + * + * @return mixed The processed data + */ + private function replace_in_data( $data, $old_url, $new_url ) { + if ( is_array( $data ) ) { + // Process arrays recursively + foreach ( $data as $key => $value ) { + $data[ $key ] = $this->replace_in_data( $value, $old_url, $new_url ); + } + } elseif ( is_object( $data ) ) { + // Process objects recursively + foreach ( $data as $key => $value ) { + $data->$key = $this->replace_in_data( $value, $old_url, $new_url ); + } + } elseif ( is_string( $data ) ) { + // Replace URLs in strings + if ( $this->handle_image_sizes ) { + $data = $this->replace_image_urls( $data, $old_url, $new_url ); + } else { + $data = str_replace( $old_url, $new_url, $data ); + } + } + + return $data; + } + + /** + * Check if a string is serialized + * + * @param string $data String to check. + * + * @return bool True if serialized + */ + private function is_serialized( $data ) { + // If it isn't a string, it isn't serialized + if ( ! is_string( $data ) ) { + return false; + } + + $data = trim( $data ); + if ( 'N;' === $data ) { + return true; + } + + if ( strlen( $data ) < 4 ) { + return false; + } + + if ( ':' !== $data[1] ) { + return false; + } + + $last_char = substr( $data, -1 ); + if ( ';' !== $last_char && '}' !== $last_char ) { + return false; + } + + $token = $data[0]; + switch ( $token ) { + case 's': + if ( '"' !== substr( $data, -2, 1 ) ) { + return false; + } + // Fall through + case 'a': + case 'O': + case 'i': + case 'd': + return (bool) preg_match( "/^{$token}:[0-9]+:/", $data ); + default: + return false; + } + } +} + +/** + * Example usage: + * + * $replacer = new Optml_Attachment_Db_Renamer(); + * + * // Replace all variations of the image + * $count = $replacer->replace( + * 'http://om-wp.test/wp-content/uploads/2025/03/image.jpg', + * 'http://om-wp.test/wp-content/uploads/2025/03/new-name.jpg' + * ); + * + * echo "Replaced $count instances"; + */ diff --git a/inc/media_rename/attachment_edit.php b/inc/media_rename/attachment_edit.php new file mode 100644 index 00000000..c03cfa94 --- /dev/null +++ b/inc/media_rename/attachment_edit.php @@ -0,0 +1,357 @@ +<?php +/** + * Attachment edit class. + */ + +/** + * Optml_Attachment_Edit + * + * @since 4.0.0 + */ +class Optml_Attachment_Edit { + /** + * Initialize the attachment edit class. + * + * @return void + */ + public function init() { + add_filter( 'attachment_fields_to_edit', [ $this, 'add_attachment_fields' ], 10, 2 ); + add_filter( 'attachment_fields_to_save', [ $this, 'prepare_attachment_filename' ], 10, 2 ); + + add_action( 'edit_attachment', [ $this, 'save_attachment_filename' ] ); + add_action( 'optml_after_attachment_url_replace', [ $this, 'bust_cache_on_rename' ], 10, 3 ); + add_action( 'optml_attachment_replaced', [ $this, 'bust_cache_on_replace' ] ); + add_action( 'wp_ajax_optml_replace_file', [ $this, 'replace_file' ] ); + + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + } + + /** + * Enqueue scripts. + * + * @param string $hook The hook. + */ + public function enqueue_scripts( $hook ) { + if ( $hook !== 'post.php' ) { + return; + } + + $id = (int) sanitize_text_field( $_GET['post'] ); + + if ( ! $id ) { + return; + } + + if ( ! current_user_can( 'edit_post', $id ) ) { + return; + } + + if ( get_post_type( $id ) !== 'attachment' ) { + return; + } + + $mime_type = get_post_mime_type( $id ); + + $max_file_size = wp_max_upload_size(); + // translators: %s is the max file size in MB. + $max_file_size_error = sprintf( __( 'File size is too large. Max file size is %sMB', 'optimole-wp' ), $max_file_size / 1024 / 1024 ); + + wp_enqueue_style( 'optml-attachment-edit', OPTML_URL . 'assets/css/single-attachment.css', [], OPTML_VERSION ); + + wp_register_script( 'optml-attachment-edit', OPTML_URL . 'assets/js/single-attachment.js', [ 'jquery' ], OPTML_VERSION, true ); + wp_localize_script( + 'optml-attachment-edit', + 'OMAttachmentEdit', + [ + 'ajaxURL' => admin_url( 'admin-ajax.php' ), + 'maxFileSize' => $max_file_size, + 'attachmentId' => $id, + 'mimeType' => $mime_type, + 'i18n' => [ + 'maxFileSizeError' => $max_file_size_error, + 'replaceFileError' => __( 'Error replacing file', 'optimole-wp' ), + ], + ] + ); + wp_enqueue_script( 'optml-attachment-edit' ); + } + + /** + * Add fields to attachment edit form. + * + * @param array $form_fields Array of form fields. + * @param WP_Post $post The post object. + * + * @return array Modified form fields. + */ + public function add_attachment_fields( $form_fields, $post ) { + $screen = get_current_screen(); + + $attachment = new Optml_Attachment_Model( $post->ID ); + + if ( ! $attachment->can_be_renamed_or_replaced() ) { + return $form_fields; + } + + if ( ! isset( $screen ) ) { + return $form_fields; + } + + if ( $screen->parent_base !== 'upload' ) { + return $form_fields; + } + + $form_fields['optml_rename_file'] = [ + 'label' => __( 'Rename attached file', 'optimole-wp' ), + 'input' => 'html', + 'html' => $this->get_rename_field( $attachment ), + ]; + + $form_fields['optml_replace_file'] = [ + 'label' => __( 'Replace file', 'optimole-wp' ), + 'input' => 'html', + 'html' => $this->get_replace_field( $attachment ), + ]; + + $form_fields['optml_footer_row'] = [ + 'label' => '', + 'input' => 'html', + 'html' => $this->get_footer_html(), + ]; + + $form_fields['optml_spacer_row'] = [ + 'label' => '', + 'input' => 'html', + 'html' => '<div></div>', + ]; + + return $form_fields; + } + + /** + * Get the rename field HTML. + * + * @param \Optml_Attachment_Model $attachment The attachment model. + * + * @return string The HTML. + */ + private function get_rename_field( \Optml_Attachment_Model $attachment ) { + $file_name_no_ext = $attachment->get_filename_no_ext(); + $file_ext = $attachment->get_extension(); + + $html = ''; + + $html .= '<div class="optml-rename-media-container">'; + + $html .= '<div class="optml-rename-input">'; + $html .= '<input type="text" id="optml_rename_file" name="optml_rename_file" placeholder="' . esc_attr( $file_name_no_ext ) . '">'; + $html .= '<span class="optml-file-ext">.' . esc_html( $file_ext ) . '</span>'; + $html .= '</div>'; + + $html .= '<button type="button" disabled class="button optml-btn primary" id="optml-rename-file-btn">' . __( 'Rename', 'optimole-wp' ) . '</button>'; + $html .= '</div>'; + + $html .= '<input type="hidden" name="optml_current_ext" value="' . esc_attr( $file_ext ) . '">'; + + wp_nonce_field( 'optml_rename_media_nonce', 'optml_rename_nonce' ); + + return $html; + } + + /** + * Get the replace field HTML. + * + * @param \Optml_Attachment_Model $attachment The attachment model. + * + * @return string The HTML. + */ + private function get_replace_field( \Optml_Attachment_Model $attachment ) { + $file_ext = $attachment->get_extension(); + $file_ext = in_array( $file_ext, [ 'jpg', 'jpeg' ], true ) ? [ '.jpg', '.jpeg' ] : [ '.' . $file_ext ]; + $html = '<div class="optml-replace-section">'; + $html .= '<div class="optml-replace-input">'; + $html .= '<label for="optml-replace-file-field" id="optml-file-drop-area">'; + $html .= '<span class="label-text">' . __( 'Click to select a file or drag & drop here', 'optimole-wp' ) . ' (' . implode( ',', $file_ext ) . ')</span>'; + $html .= '<div class="optml-replace-file-preview"></div>'; + $html .= '</label>'; + + $html .= '<input type="file" class="hidden" id="optml-replace-file-field" name="optml-replace-file-field" accept="' . implode( ',', $file_ext ) . '">'; + + $html .= '<div class="optml-replace-file-actions">'; + $html .= '<button disabled type="button" class="button optml-btn primary" id="optml-replace-file-btn">' . __( 'Replace file', 'optimole-wp' ) . '</button>'; + $html .= '<button disabled type="button" class="button optml-btn destructive" id="optml-replace-clear-btn">' . __( 'Clear', 'optimole-wp' ) . '</button>'; + $html .= $this->get_svg_loader(); + $html .= '<p class="optml-description">' . __( 'This will replace the current file with the new one. This action cannot be undone.', 'optimole-wp' ) . '</p>'; + $html .= '</div>'; + + $html .= '<div class="optml-replace-file-error hidden"></div>'; + + $html .= '</div>'; + + return $html; + } + + /** + * Get the footer HTML. + * + * @return string The HTML. + */ + private function get_footer_html() { + $html = ''; + $html .= '<div class="optml-logo-contianer">'; + $html .= '<img src="' . OPTML_URL . 'assets/img/logo.svg" alt="' . __( 'Optimole logo', 'optimole-wp' ) . '"/>'; + // translators: %s is the 'Optimole'. + $html .= '<span>' . sprintf( __( 'Powered by %s', 'optimole-wp' ), '<strong>Optimole</strong>' ) . '</span>'; + $html .= '</div>'; + + return $html; + } + + + /** + * Prepare the new filename before saving. + * + * @param array $post_data Array of post data. + * @param array $attachment Array of attachment data. + * + * @return array Modified post data. + */ + public function prepare_attachment_filename( array $post_data, array $attachment ) { + if ( ! current_user_can( 'edit_post', $post_data['ID'] ) ) { + return $post_data; + } + + if ( ! isset( $post_data['post_type'] ) || $post_data['post_type'] !== 'attachment' ) { + return $post_data; + } + + if ( ! isset( $post_data['optml_rename_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $post_data['optml_rename_nonce'] ), 'optml_rename_media_nonce' ) ) { + return $post_data; + } + + $new_name = sanitize_text_field( $post_data['optml_rename_file'] ); + + $new_name = trim( $new_name ); + + if ( empty( $new_name ) ) { + return $post_data; + } + + if ( strlen( $new_name ) > 100 ) { + return $post_data; + } + + /** + * Store filename for later, it will be used to rename the attachment. + * + * We do it this way because we don't want to rename the attachment immediately, during the attachment fields update as it will break things. + * + * @see Optml_Attachment_Edit::save_attachment_filename() + */ + update_post_meta( $post_data['ID'], '_optml_pending_rename', $new_name ); + + return $post_data; + } + + /** + * Save the new filename when attachment is updated + * + * @param int $post_id The post ID. + */ + public function save_attachment_filename( $post_id ) { + $new_filename = get_post_meta( $post_id, '_optml_pending_rename', true ); + + if ( empty( $new_filename ) ) { + return; + } + + // Delete the meta so we don't rename again + delete_post_meta( $post_id, '_optml_pending_rename' ); + + $renamer = new Optml_Attachment_Rename( $post_id, $new_filename ); + $status = $renamer->rename(); + + if ( is_wp_error( $status ) ) { + wp_die( $status->get_error_message() ); + } + } + + /** + * Replace the file + */ + public function replace_file() { + $id = (int) sanitize_text_field( $_POST['attachment_id'] ); + + if ( ! current_user_can( 'edit_post', $id ) ) { + wp_send_json_error( __( 'You are not allowed to replace this file', 'optimole-wp' ) ); + } + + if ( ! isset( $_FILES['file'] ) ) { + wp_send_json_error( __( 'No file uploaded', 'optimole-wp' ) ); + } + + $replacer = new Optml_Attachment_Replace( $id, $_FILES['file'] ); + + $replaced = $replacer->replace(); + + $is_error = is_wp_error( $replaced ); + + $response = [ + 'success' => ! $is_error, + 'message' => $is_error ? $replaced->get_error_message() : __( 'File replaced successfully', 'optimole-wp' ), + ]; + + wp_send_json( $response ); + } + + /** + * Bust cached assets when an attachment is renamed. + * + * @param int $attachment_id The attachment ID. + * @param string $old_url The old attachment URL. + * @param string $new_url The new attachment URL. + */ + public function bust_cache_on_rename( $attachment_id, $old_url, $new_url ) { + $this->clear_cache(); + } + + /** + * Bust cached assets when an attachment is replaced. + * + * @param int $attachment_id The attachment ID. + * + * @return void + */ + public function bust_cache_on_replace( $attachment_id ) { + $this->clear_cache(); + } + + /** + * Clear the cache for third-party plugins. + * + * @return void + */ + private function clear_cache() { + if ( + class_exists( '\ThemeIsle\GutenbergBlocks\Server\Dashboard_Server' ) && + is_callable( [ '\ThemeIsle\GutenbergBlocks\Server\Dashboard_Server', 'regenerate_styles' ] ) + ) { + \ThemeIsle\GutenbergBlocks\Server\Dashboard_Server::regenerate_styles(); + } + + if ( did_action( 'elementor/loaded' ) ) { + if ( class_exists( '\Elementor\Plugin' ) ) { + \Elementor\Plugin::instance()->files_manager->clear_cache(); + } + } + } + + /** + * Get the SVG loader. + * + * @return string The SVG loader. + */ + private function get_svg_loader() { + return '<svg style="display: none;" class="optml-svg-loader" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; + } +} diff --git a/inc/media_rename/attachment_model.php b/inc/media_rename/attachment_model.php new file mode 100644 index 00000000..0fa91b2f --- /dev/null +++ b/inc/media_rename/attachment_model.php @@ -0,0 +1,289 @@ +<?php +/** + * Attachment model class. + */ + +/** + * Optml_Attachment_Model + * + * @since 4.0.0 + */ +class Optml_Attachment_Model { + use Optml_Dam_Offload_Utils; + + /** + * The attachment ID. + * + * @var int + */ + private $attachment_id; + + /** + * The attachment file extension. + * + * @var string + */ + private $extension; + + /** + * The attachment file name including extension. + * + * @var string + */ + private $original_attached_file_name; + + /** + * The attachment file name without extension. + * + * @var string + */ + private $filename_no_ext; + + /** + * The original attached full file path (even if it's scaled). + * + * @var string + */ + private $origianal_attached_file_path; + + /** + * The original attached file directory path. + * + * @var string + */ + private $dir_path; + + /** + * Whether the attachment is scaled. + * + * @var bool + */ + private $is_scaled = false; + + /** + * The attached file main URL. + * + * @var string + */ + private $main_attachment_url; + + /** + * Whether the attachment is remote - offloaded, imported from DAM or legacy offloaded. + * + * @var bool + */ + private $is_remote_attachment; + + /** + * The attachment metadata. + * + * @var array + */ + public $attachment_metadata; + + /** + * Constructor. + * + * @param int $attachment_id Attachment ID. + * + * @return void + */ + public function __construct( int $attachment_id ) { + $this->attachment_id = $attachment_id; + $this->attachment_metadata = wp_get_attachment_metadata( $this->attachment_id ); + + $mime_type = get_post_mime_type( $attachment_id ); + $is_image = strpos( $mime_type, 'image' ) !== false; + + $this->main_attachment_url = $is_image ? wp_get_original_image_url( $attachment_id ) : wp_get_attachment_url( $attachment_id ); + $this->origianal_attached_file_path = $this->setup_original_attached_file(); + $this->dir_path = dirname( $this->origianal_attached_file_path ); + $this->is_scaled = isset( $this->attachment_metadata['original_image'] ); + + $filename = $this->is_scaled && isset( $this->attachment_metadata['original_image'] ) ? + $this->attachment_metadata['original_image'] : + basename( $this->origianal_attached_file_path ); + + $this->original_attached_file_name = $filename; + + $file_parts = pathinfo( $filename ); + + $this->extension = isset( $file_parts['extension'] ) ? $file_parts['extension'] : ''; + $this->filename_no_ext = $file_parts['filename']; + $this->is_remote_attachment = $this->is_dam_imported_image( $this->attachment_id ) || + $this->is_legacy_offloaded_attachment( $this->attachment_id ) || + $this->is_new_offloaded_attachment( $this->attachment_id ); + } + + /** + * Check if the attachment is scaled. + * + * @return bool + */ + public function is_scaled() { + return $this->is_scaled; + } + + /** + * Get attachment ID. + * + * @return int + */ + public function get_attachment_id() { + return $this->attachment_id; + } + + /** + * Get filename no extension. + * + * @return string + */ + public function get_filename_no_ext() { + return $this->filename_no_ext; + } + + /** + * Get filename with extension. + * + * @param bool $scaled Whether the filename is scaled. + * + * @return string + */ + public function get_filename_with_ext( $scaled = false ) { + if ( $scaled ) { + return sprintf( '%s-scaled.%s', $this->filename_no_ext, $this->extension ); + } + + return sprintf( '%s.%s', $this->filename_no_ext, $this->extension ); + } + + /** + * Get extension. + * + * @return string + */ + public function get_extension() { + return $this->extension; + } + + /** + * Get source file path. + * + * Returns the original attached file path, if the attachment is scaled, it will return the unscaled file path. + * + * @return string + */ + public function get_source_file_path() { + return $this->origianal_attached_file_path; + } + + /** + * Get dir path. + * + * @return string + */ + public function get_dir_path() { + return $this->dir_path; + } + + /** + * Get the attachment file main url. + * + * @return string + */ + public function get_main_url() { + return $this->main_attachment_url; + } + + /** + * Get attachment metadata. + * + * @return array + */ + public function get_attachment_metadata() { + return $this->attachment_metadata; + } + + /** + * Get all image sizes paths. + * + * @return array + */ + public function get_all_image_sizes_paths() { + $paths = []; + + if ( ! isset( $this->attachment_metadata['sizes'] ) ) { + return []; + } + + foreach ( $this->attachment_metadata['sizes'] as $size => $size_data ) { + $paths[ $size ] = $this->dir_path . '/' . $size_data['file']; + } + + return $paths; + } + + /** + * Get all image sizes URLs. + * + * @return array + */ + public function get_all_image_sizes_urls() { + $attachment_metadata = $this->attachment_metadata; + + $links = []; + + if ( ! isset( $attachment_metadata['sizes'] ) ) { + return []; + } + + foreach ( $attachment_metadata['sizes'] as $size => $size_data ) { + $links[ $size ] = str_replace( $this->original_attached_file_name, $size_data['file'], $this->get_main_url() ); + } + + return $links; + } + + /** + * Get attached file. + * + * @return string + */ + private function setup_original_attached_file() { + $attachment_metadata = wp_get_attachment_metadata( $this->attachment_id ); + $attached_file = get_attached_file( $this->attachment_id ); + $file_name = basename( $attached_file ); + + if ( isset( $attachment_metadata['original_image'] ) ) { + return str_replace( $file_name, $attachment_metadata['original_image'], $attached_file ); + } + + return $attached_file; + } + + /** + * Get metadata 'file' key prefix path. + * + * @return string + */ + public function get_metadata_prefix_path() { + if ( ! isset( $this->attachment_metadata['file'] ) ) { + $attached_file = get_post_meta( $this->attachment_id, '_wp_attached_file', true ); + + return dirname( $attached_file ); + } + + $file_path = $this->attachment_metadata['file']; + + return dirname( $file_path ); + } + + /** + * Check if can be renamed/replaced. + * + * @return bool + */ + public function can_be_renamed_or_replaced() { + return ! $this->is_remote_attachment; + } +} diff --git a/inc/media_rename/attachment_rename.php b/inc/media_rename/attachment_rename.php new file mode 100644 index 00000000..f6b29938 --- /dev/null +++ b/inc/media_rename/attachment_rename.php @@ -0,0 +1,215 @@ +<?php + +/** + * Attachment rename class. + */ +class Optml_Attachment_Rename { + /** + * The attachment ID. + * + * @var int + */ + private $attachment_id; + + /** + * The new filename. + * + * @var string + */ + private $new_filename; + + /** + * The attachment model. + * + * @var Optml_Attachment_Model + */ + private $attachment; + + /** + * Constructor. + * + * @param int $attachment_id Attachment ID. + * @param string $new_filename New filename. + * @return void + */ + public function __construct( int $attachment_id, string $new_filename ) { + $this->attachment_id = $attachment_id; + $this->attachment = new Optml_Attachment_Model( $attachment_id ); + $this->new_filename = $new_filename; + + if ( ! function_exists( 'WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + WP_Filesystem(); + } + + /** + * Rename the attachment + * + * @return bool|WP_Error + */ + public function rename() { + if ( empty( $this->new_filename ) || sanitize_file_name( $this->new_filename ) === $this->attachment->get_filename_no_ext() ) { + return true; + } + + $extension = $this->attachment->get_extension(); + + $base_dir = trailingslashit( $this->attachment->get_dir_path() ); + $file_path = $this->attachment->get_source_file_path(); + + $new_file_with_ext = sprintf( '%s.%s', $this->new_filename, $extension ); + $new_unique_filename = wp_unique_filename( $base_dir, $new_file_with_ext ); + $new_file_path = $base_dir . $new_unique_filename; + + global $wp_filesystem; + + // Bail if original file doesn't exist. + if ( ! $wp_filesystem->exists( $file_path ) ) { + return new WP_Error( 'optml_attachment_file_not_found', __( 'Error renaming file.', 'optimole-wp' ) ); + } + + // Rename the file (move) - moves the original, not the scaled image. + $moved = $wp_filesystem->move( $file_path, $new_file_path ); + if ( ! $moved ) { + return new WP_Error( 'optml_attachment_rename_failed', __( 'Error renaming file.', 'optimole-wp' ) ); + } + + $wp_filesystem->chmod( $new_file_path, FS_CHMOD_FILE ); + + // Move the scaled image if it exists. + if ( $this->attachment->is_scaled() ) { + $new_unique_filename_no_ext = pathinfo( $new_unique_filename, PATHINFO_FILENAME ); + + $scaled_old_file_path = sprintf( '%s/%s-scaled.%s', $this->attachment->get_dir_path(), $this->attachment->get_filename_no_ext(), $extension ); + $scaled_new_file_with_ext = sprintf( '%s-scaled.%s', $new_unique_filename_no_ext, $extension ); + + $new_scaled_file_path = $base_dir . $scaled_new_file_with_ext; + // Move the scaled image. We also override any leftover scaled files. + $wp_filesystem->move( $scaled_old_file_path, $new_scaled_file_path, true ); + } + + // Update attachment metadata + $metadata_update = $this->update_attachment_metadata( $new_file_path ); + + if ( $metadata_update === false ) { + return new WP_Error( 'optml_attachment_metadata_update_failed', __( 'Error renaming file.', 'optimole-wp' ) ); + } + + try { + $replacer = new Optml_Attachment_Db_Renamer(); + $old_url = $this->attachment->get_main_url(); + $new_url = $this->get_new_url( $new_unique_filename ); + + $count = $replacer->replace( $old_url, $new_url ); + + if ( $count > 0 ) { + /** + * Action triggered after the attachment file is renamed. + * + * @param int $attachment_id Attachment ID. + * @param string $old_url Old attachment URL. + * @param string $new_url New attachment URL. + */ + do_action( 'optml_after_attachment_url_replace', $this->attachment_id, $old_url, $new_url ); + } + } catch ( Exception $e ) { + return new WP_Error( 'optml_attachment_url_replace_failed', __( 'Error renaming file.', 'optimole-wp' ) ); + } + + do_action( 'optml_attachment_renamed', $this->attachment_id ); + + return true; + } + + /** + * Update attachment metadata. + * + * @param string $new_path New path. + * @return bool + */ + private function update_attachment_metadata( $new_path ) { + global $wp_filesystem; + + $new_file_name_no_ext = pathinfo( $new_path, PATHINFO_FILENAME ); + $extension = $this->attachment->get_extension(); + + if ( $this->attachment->is_scaled() ) { + $new_path = sprintf( '%s/%s-scaled.%s', $this->attachment->get_metadata_prefix_path(), $new_file_name_no_ext, $extension ); + } + + $attached_update = update_attached_file( $this->attachment_id, $new_path ); + + if ( ! $attached_update ) { + return false; + } + + // Get current attachment metadata + $metadata = $this->attachment->get_attachment_metadata(); + + if ( empty( $metadata ) ) { + return false; + } + + // Update file path in metadata + $original_image = sprintf( '%s.%s', $new_file_name_no_ext, $extension ); + $meta_file = $original_image; + + if ( $this->attachment->is_scaled() ) { + $meta_file = sprintf( '%s-scaled.%s', $new_file_name_no_ext, $extension ); + $metadata['original_image'] = $original_image; + } + + if ( isset( $metadata['file'] ) ) { + $metadata['file'] = sprintf( '%s/%s', $this->attachment->get_metadata_prefix_path(), $meta_file ); + } + + // Update image sizes if they exist + if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) { + $already_moved_paths = []; + + foreach ( $metadata['sizes'] as $size => $size_data ) { + if ( ! isset( $size_data['file'] ) ) { + continue; + } + $size_suffix = $size_data['width'] . 'x' . $size_data['height']; + $new_size_file = sprintf( '%s-%s.%s', $new_file_name_no_ext, $size_suffix, $extension ); + + $old_size_file_path = sprintf( '%s/%s', $this->attachment->get_dir_path(), $size_data['file'] ); + $new_size_file_path = sprintf( '%s/%s', $this->attachment->get_dir_path(), $new_size_file ); + + $move = $wp_filesystem->move( $old_size_file_path, $new_size_file_path ); + + if ( $move || in_array( $old_size_file_path, $already_moved_paths, true ) ) { + $already_moved_paths[] = $old_size_file_path; + $metadata['sizes'][ $size ]['file'] = $new_size_file; + $already_moved_paths = array_unique( $already_moved_paths ); + } + } + } + + $metadata_update = wp_update_attachment_metadata( $this->attachment_id, $metadata ); + + if ( ! $metadata_update ) { + return false; + } + + return true; + } + + /** + * Get the new main attached file URL. + * + * @return string + */ + private function get_new_url( $filename ) { + $url = $this->attachment->get_main_url(); + + return str_replace( + basename( $url ), + $filename, + $url + ); + } +} diff --git a/inc/media_rename/attachment_replace.php b/inc/media_rename/attachment_replace.php new file mode 100644 index 00000000..c5b4c9fa --- /dev/null +++ b/inc/media_rename/attachment_replace.php @@ -0,0 +1,188 @@ +<?php + +/** + * Optml_Attachment_Replace + * + * @since 4.0.0 + */ +class Optml_Attachment_Replace { + + /** + * Attachment ID. + * + * @var int + */ + private $attachment_id; + + /** + * File. + * + * @var array + */ + private $file; + + /** + * Attachment. + * + * @var \Optml_Attachment_Model + */ + private $attachment; + + /** + * New attachment. + * + * @var \Optml_Attachment_Model + */ + private $new_attachment; + + /** + * Constructor. + * + * @param int $attachment_id Attachment ID. + * @param array $file File. + */ + public function __construct( $attachment_id, $file ) { + $this->attachment_id = $attachment_id; + $this->file = $file; + $this->attachment = new Optml_Attachment_Model( $attachment_id ); + + if ( ! function_exists( 'WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + WP_Filesystem(); + } + + /** + * Replace the attachment. + * + * @return bool|WP_Error + */ + public function replace() { + if ( ! file_exists( $this->file['tmp_name'] ) ) { + return new WP_Error( 'file_error', __( 'Error uploading file.', 'optimole-wp' ) ); + } + + $original_file = $this->attachment->get_source_file_path(); + $old_sizes_urls = $this->attachment->get_all_image_sizes_urls(); + + if ( ! file_exists( $original_file ) ) { + return new WP_Error( 'file_error', __( 'Original file does not exist.', 'optimole-wp' ) ); + } + + $original_filetype = wp_check_filetype( $original_file ); + $uploaded_filetype = wp_check_filetype( $this->file['name'] ); + + if ( $original_filetype['type'] !== $uploaded_filetype['type'] ) { + return new WP_Error( 'file_error', __( 'The uploaded file type does not match the original file type.', 'optimole-wp' ) ); + } + + global $wp_filesystem; + + if ( ! $wp_filesystem->move( $this->file['tmp_name'], $original_file, true ) ) { + return new WP_Error( 'file_error', __( 'Could not move file.', 'optimole-wp' ) ); + } + + $wp_filesystem->chmod( $original_file, FS_CHMOD_FILE ); + + $this->remove_all_image_sizes(); + + clean_attachment_cache( $this->attachment_id ); + + $metadata = wp_generate_attachment_metadata( $this->attachment_id, $original_file ); + + if ( isset( $metadata['sizes'] ) ) { + $this->replace_image_sizes_links( $metadata['sizes'], $old_sizes_urls ); + } + + wp_update_attachment_metadata( $this->attachment_id, $metadata ); + $this->new_attachment = new Optml_Attachment_Model( $this->attachment_id ); + + $this->handle_scaled_images(); + + do_action( 'optml_attachment_replaced', $this->attachment_id ); + + return true; + } + + /** + * Remove all image sizes files. + * + * @return void + */ + private function remove_all_image_sizes() { + $all_image_sizes_paths = $this->attachment->get_all_image_sizes_paths(); + global $wp_filesystem; + + foreach ( $all_image_sizes_paths as $path ) { + if ( file_exists( $path ) ) { + $wp_filesystem->delete( $path ); + } + } + } + + /** + * Handle scaled images. + * + * @return bool + */ + private function handle_scaled_images() { + global $wp_filesystem; + + $old_scaled = $this->attachment->is_scaled(); + $new_scaled = $this->new_attachment->is_scaled(); + $replacer = new Optml_Attachment_Db_Renamer( true ); + + $new_file_path = $this->new_attachment->get_source_file_path(); + $file = apply_filters( 'update_attached_file', $new_file_path, $this->attachment_id ); + + // New is scaled, but old is not scaled. We don't replace anything. + if ( $old_scaled === $new_scaled || ( ! $old_scaled && $new_scaled ) ) { + return true; + } + + // Delete the old scaled version and replace scaled URLs with non-scaled URLs. + if ( $old_scaled && ! $new_scaled ) { + $main_file_url = $this->attachment->get_main_url(); + $unscaled_file = $this->attachment->get_filename_with_ext(); + $old_scaled_file = $this->attachment->get_filename_with_ext( true ); + $old_scaled_url = str_replace( $unscaled_file, $old_scaled_file, $main_file_url ); + + $replacer->replace( $old_scaled_url, $main_file_url ); + + // replace the old year/month/item-scaled.ext with the new year/month/item.ext + $scaled_path = str_replace( $unscaled_file, $old_scaled_file, $this->attachment->get_source_file_path() ); + if ( file_exists( $scaled_path ) ) { + $wp_filesystem->delete( $scaled_path ); + } + + update_attached_file( $this->attachment_id, sprintf( '%s/%s', $this->attachment->get_metadata_prefix_path(), $unscaled_file ) ); + } + + return true; + } + + /** + * Replace image sizes links. + * + * @param array $new_sizes New sizes. + * @param array $old_sizes_urls Old sizes URLs. + * + * @return void + */ + private function replace_image_sizes_links( $new_sizes, $old_sizes_urls ) { + $replacer = new Optml_Attachment_Db_Renamer( true ); + + foreach ( $old_sizes_urls as $size => $old_url ) { + // If the size is not in the new sizes, we need to use the original URL. + if ( ! isset( $new_sizes[ $size ], $new_sizes[ $size ]['file'] ) ) { + $replacer->replace( $old_url, $this->attachment->get_main_url() ); + continue; + } + + // If the size is in the new sizes, we need to use the new URL. + $new_url = str_replace( $this->attachment->get_filename_with_ext(), $new_sizes[ $size ]['file'], $this->attachment->get_main_url() ); + $replacer->replace( $old_url, $new_url ); + } + } +} diff --git a/optimole-wp.php b/optimole-wp.php index cca58a2a..48011b0a 100644 --- a/optimole-wp.php +++ b/optimole-wp.php @@ -26,7 +26,7 @@ function optml_autoload( $class_name ) { if ( strpos( $class_name, $prefix ) !== 0 ) { return; } - foreach ( [ '/inc/', '/inc/traits/', '/inc/image_properties/', '/inc/asset_properties/', '/inc/compatibilities/', '/inc/conflicts/', '/inc/cli/' ] as $folder ) { + foreach ( [ '/inc/', '/inc/traits/', '/inc/image_properties/', '/inc/asset_properties/', '/inc/compatibilities/', '/inc/conflicts/', '/inc/cli/', '/inc/media_rename/' ] as $folder ) { $file = str_replace( $prefix . '_', '', $class_name ); $file = strtolower( $file ); $file = __DIR__ . $folder . $file . '.php'; diff --git a/tests/assets/large-1.jpg b/tests/assets/large-1.jpg new file mode 100644 index 00000000..1865b519 Binary files /dev/null and b/tests/assets/large-1.jpg differ diff --git a/tests/assets/large-2.jpg b/tests/assets/large-2.jpg new file mode 100644 index 00000000..fc3e4cbd Binary files /dev/null and b/tests/assets/large-2.jpg differ diff --git a/tests/assets/rename-scaled.jpg b/tests/assets/rename-scaled.jpg new file mode 100644 index 00000000..f6205498 Binary files /dev/null and b/tests/assets/rename-scaled.jpg differ diff --git a/tests/assets/rename-unscaled.jpg b/tests/assets/rename-unscaled.jpg new file mode 100644 index 00000000..7f76c90f Binary files /dev/null and b/tests/assets/rename-unscaled.jpg differ diff --git a/tests/assets/small-1.jpg b/tests/assets/small-1.jpg new file mode 100644 index 00000000..72613675 Binary files /dev/null and b/tests/assets/small-1.jpg differ diff --git a/tests/assets/small-2.jpg b/tests/assets/small-2.jpg new file mode 100644 index 00000000..e4556798 Binary files /dev/null and b/tests/assets/small-2.jpg differ diff --git a/tests/media_rename/test-attachment-edit.php b/tests/media_rename/test-attachment-edit.php new file mode 100644 index 00000000..87e43bcc --- /dev/null +++ b/tests/media_rename/test-attachment-edit.php @@ -0,0 +1,52 @@ +<?php +/** + * Test class for Optml_Attachment_Edit. + */ + +/** + * Class Test_Attachment_Edit. + */ +class Test_Attachment_Edit extends WP_UnitTestCase { + /** + * Test instance + * + * @var Optml_Attachment_Edit + */ + private $instance; + + /** + * Setup test + */ + public function setUp(): void { + parent::setUp(); + $this->instance = new Optml_Attachment_Edit(); + } + + /** + * Test prepare attachment filename + */ + public function test_prepare_attachment_filename() { + $attachment = self::factory()->post->create_and_get( [ + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ] ); + + $post_data = [ + 'ID' => $attachment->ID, + 'optml_rename_nonce' => wp_create_nonce( 'optml_rename_media_nonce' ), + 'optml_rename_file' => 'test-file', + 'post_type' => 'attachment', + ]; + + $result = $this->instance->prepare_attachment_filename( $post_data, (array) $attachment ); + + foreach ( $post_data as $key => $value ) { + if ( $key == 'optml_rename_nonce' ) { + continue; + } + $this->assertEquals( $value, $result[ $key ] ); + } + + $this->assertEquals( 'test-file', get_post_meta( $attachment->ID, '_optml_pending_rename', true ) ); + } +} diff --git a/tests/media_rename/test-attachment-model.php b/tests/media_rename/test-attachment-model.php new file mode 100644 index 00000000..85277f26 --- /dev/null +++ b/tests/media_rename/test-attachment-model.php @@ -0,0 +1,137 @@ +<?php +/** + * Test class for Optml_Attachment_Model. + */ + +/** + * Class Test_Attachment_Model. + */ +class Test_Attachment_Model extends WP_UnitTestCase { + protected static $unscaled_id; + protected static $scaled_id; + protected static $remote_id; + + protected static $unscaled_model; + protected static $scaled_model; + protected static $remote_model; + + const MOCK_REMOTE_ATTACHMENT = [ + 'url' => 'https://cloudUrlTest.test/w:auto/h:auto/q:auto/id:b1b12ee03bf3945d9d9bb963ce79cd4f/https://test-site.test/9.jpg', + 'meta' => + [ + 'originalHeight' => 1800, + 'originalWidth' => 1200, + 'updateTime' => 1688553629048, + 'resourceS3' => 'randomHashForImage1', + 'mimeType' => 'image/jpeg', + 'userKey' => 'mlckcuxuuuyb', + 'fileSize' => 171114, + 'originURL' => 'https://test-site.test/wp-content/uploads/2023/07/9.jpg', + 'domain_hash' => 'dWwtcG9sZWNhdC15dWtpLmluc3Rhd3AueHl6', + ], + ]; + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + self::$scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' ); + + $plugin = Optml_Main::instance(); + self::$remote_id = $plugin->dam->insert_attachments( [ self::MOCK_REMOTE_ATTACHMENT ] )[0]; + + self::$unscaled_model = new Optml_Attachment_Model( self::$unscaled_id ); + self::$scaled_model = new Optml_Attachment_Model( self::$scaled_id ); + self::$remote_model = new Optml_Attachment_Model( self::$remote_id ); + } + + public static function tear_down_after_class() { + wp_delete_post( self::$unscaled_id, true ); + wp_delete_post( self::$scaled_id, true ); + wp_delete_post( self::$remote_id, true ); + parent::tear_down_after_class(); + } + + public function test_barebones() { + $this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_id ) ); + $this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_id ) ); + $this->assertInstanceOf( 'WP_Post', get_post( self::$remote_id ) ); + } + + public function test_models() { + $this->test_model( self::$unscaled_id, self::$unscaled_model ); + $this->test_model( self::$scaled_id, self::$scaled_model, true ); + $this->test_model( self::$remote_id, self::$remote_model, false, true ); + } + + private function test_model( $id, $model, $scaled = false, $remote = false ) { + $this->test_basic_getters( $id, $model ); + $this->test_filename_methods( $model ); + $this->test_image_sizes_methods( $model ); + $this->test_metadata_prefix_path( $model ); + $this->assertEquals( $scaled, $model->is_scaled() ); + $this->assertIsBool( $model->can_be_renamed_or_replaced() ); + $this->assertEquals( ! $remote, $model->can_be_renamed_or_replaced() ); + } + + /** + * Test basic model getters. + * + * @param int $id Post ID. + * @param Optml_Attachment_Model $model The model to test. + * + * @return void + */ + private function test_basic_getters( $id, $model ) { + $this->assertEquals( $id, $model->get_attachment_id() ); + $this->assertNotEmpty( $model->get_attachment_metadata() ); + + if( $model->can_be_renamed_or_replaced() ) { + $this->assertNotEmpty( $model->get_main_url() ); + $this->assertNotEmpty( $model->get_source_file_path() ); + $this->assertNotEmpty( $model->get_dir_path() ); + $this->assertEquals( 'jpg', $model->get_extension() ); + } else { + $this->assertFalse( $model->get_main_url() ); + $this->assertEmpty( $model->get_source_file_path() ); + $this->assertEmpty( $model->get_dir_path() ); + $this->assertEmpty( $model->get_extension() ); + } + } + + private function test_filename_methods( $model ) { + if( ! $model->can_be_renamed_or_replaced() ) { + return; + } + $this->assertNotEmpty( $model->get_filename_no_ext() ); + $this->assertEquals( $model->get_filename_with_ext(), $model->get_filename_no_ext() . '.' . $model->get_extension() ); + $this->assertEquals( $model->get_filename_with_ext( true ), $model->get_filename_no_ext() . '-scaled.' . $model->get_extension() ); + $this->assertNotEmpty( $model->get_filename_with_ext() ); + $this->assertStringContainsString( '-scaled', $model->get_filename_with_ext(true) ); + } + + private function test_image_sizes_methods( $model ) { + $sizes_paths = $model->get_all_image_sizes_paths(); + $sizes_urls = $model->get_all_image_sizes_urls(); + + $this->assertIsArray( $sizes_paths ); + $this->assertIsArray( $sizes_urls ); + + foreach ( $sizes_paths as $size => $path ) { + $this->assertNotEmpty( $path ); + $this->assertArrayHasKey( $size, $sizes_urls ); + $this->assertNotEmpty( $sizes_urls[ $size ] ); + + $this->assertArrayHasKey( $size, $sizes_paths ); + $this->assertNotEmpty( $path ); + } + } + + private function test_metadata_prefix_path( $model ) { + $prefix_path = $model->get_metadata_prefix_path(); + $this->assertNotEmpty( $prefix_path ); + $this->assertIsString( $prefix_path ); + + $metadata = $model->get_attachment_metadata(); + $this->assertArrayHasKey( 'file', $metadata ); + $this->assertStringContainsString( $prefix_path, $metadata['file'] ); + } +} diff --git a/tests/media_rename/test-attachment-rename.php b/tests/media_rename/test-attachment-rename.php new file mode 100644 index 00000000..f6c069c3 --- /dev/null +++ b/tests/media_rename/test-attachment-rename.php @@ -0,0 +1,78 @@ +<?php +/** + * Test class for Optml_Attachment_Rename. + */ + +/** + * Class Test_Attachment_Rename. + */ +class Test_Attachment_Rename extends WP_UnitTestCase { + protected static $scaled_id; + protected static $unscaled_id; + + protected static $scaled_model; + protected static $unscaled_model; + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/rename-scaled.jpg' ); + self::$unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/rename-unscaled.jpg' ); + + self::$scaled_model = new Optml_Attachment_Model( self::$scaled_id ); + self::$unscaled_model = new Optml_Attachment_Model( self::$unscaled_id ); + } + + public static function tear_down_after_class() { + wp_delete_post( self::$scaled_id, true ); + wp_delete_post( self::$unscaled_id, true ); + parent::tear_down_after_class(); + } + + public function test_barebones() { + $this->assertInstanceOf( 'WP_Post' , get_post( self::$scaled_id ) ); + $this->assertInstanceOf( 'WP_Post' , get_post( self::$unscaled_id ) ); + + $this->assertEquals('attachment', get_post_type( self::$scaled_id ) ); + $this->assertEquals('attachment', get_post_type( self::$unscaled_id ) ); + } + + public function test_renames() { + $this->test_rename( self::$unscaled_id, self::$unscaled_model, 'renamed-image' ); + $this->test_rename( self::$scaled_id, self::$scaled_model, 'big-file-rename', true ); + } + + private function test_rename( $id, $model, $new_filename, $scaled = false ) { + $renamer = new Optml_Attachment_Rename( $id, $new_filename ); + $result = $renamer->rename(); + + $this->assertTrue( $result ); + + $new_model = new Optml_Attachment_Model( $id ); + + $this->assertStringContainsString( $new_filename, $new_model->get_filename_no_ext() ); + $this->check_rename_with_models( $new_model, $model, $scaled ); + } + + private function check_rename_with_models( $new, $old, $scaled = false ) { + $this->assertNotEquals( $old->get_filename_no_ext(), $new->get_filename_no_ext() ); + + $old_meta = $old->get_attachment_metadata(); + $new_meta = $new->get_attachment_metadata(); + + $this->assertStringContainsString( $new->get_filename_with_ext($scaled), $new_meta['file'] ); + $this->assertStringContainsString( $old->get_filename_with_ext($scaled), $old_meta['file'] ); + + foreach ( $old_meta['sizes'] as $id => $size_args ) { + $this->assertArrayHasKey( $id, $new_meta['sizes'] ); + $this->assertNotEquals( $size_args['file'], $new_meta['sizes'][ $id ]['file'] ); + $this->assertStringContainsString( $new->get_filename_no_ext(), $new_meta['sizes'][ $id ]['file'] ); + $this->assertStringContainsString( $old->get_filename_no_ext(), $old_meta['sizes'][ $id ]['file'] ); + } + + // check actual files. + $new_src = $new->get_source_file_path(); + $old_src = $old->get_source_file_path(); + + $this->assertTrue( file_exists( $new_src ) ); + $this->assertFalse( file_exists( $old_src ) ); + } +} diff --git a/tests/media_rename/test-attachment-replace.php b/tests/media_rename/test-attachment-replace.php new file mode 100644 index 00000000..fa170670 --- /dev/null +++ b/tests/media_rename/test-attachment-replace.php @@ -0,0 +1,140 @@ +<?php +/** + * Test class for Optml_Attachment_Replace. + */ + +/** + * Class Test_Attachment_Replace. + */ +class Test_Attachment_Replace extends WP_UnitTestCase { + protected static $scaled_unscaled_id; // scaled -> unscaled + protected static $unscaled_scaled_id; // unscaled -> scaled + protected static $scaled_scaled_id; // scaled -> scaled + protected static $unscaled_unscaled_id; // unscaled -> unscaled + + const FILESTASH = OPTML_PATH . 'tests/assets/filestash/'; + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + if ( ! function_exists( 'WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + WP_Filesystem(); + global $wp_filesystem; + + // Ensure filestash exists and is empty + if ( $wp_filesystem->exists( self::FILESTASH ) ) { + $wp_filesystem->rmdir( self::FILESTASH, true ); + } + $wp_filesystem->mkdir( self::FILESTASH ); + + // Copy replacement files + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/large-1.jpg', self::FILESTASH . 'replace-scaled.jpg' ); + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/large-2.jpg', self::FILESTASH . 'replace-scaled-alt.jpg' ); // Different file for scaled->scaled + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', self::FILESTASH . 'replace-unscaled.jpg' ); + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-2.jpg', self::FILESTASH . 'replace-unscaled-alt.jpg' ); // Different file for unscaled->unscaled + + + // Create initial attachments for each test case + self::$scaled_unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' ); // Scaled + self::$unscaled_scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); // Unscaled + self::$scaled_scaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/3000x3000.jpg' ); // Scaled + self::$unscaled_unscaled_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); // Unscaled + } + + public static function tear_down_after_class() { + // Delete all created attachments + wp_delete_post( self::$scaled_unscaled_id, true ); + wp_delete_post( self::$unscaled_scaled_id, true ); + wp_delete_post( self::$scaled_scaled_id, true ); + wp_delete_post( self::$unscaled_unscaled_id, true ); + + global $wp_filesystem; + $wp_filesystem->rmdir( self::FILESTASH, true ); + + parent::tear_down_after_class(); + } + + public function test_barebones() { + $this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_unscaled_id ) ); + $this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_scaled_id ) ); + $this->assertInstanceOf( 'WP_Post', get_post( self::$scaled_scaled_id ) ); + $this->assertInstanceOf( 'WP_Post', get_post( self::$unscaled_unscaled_id ) ); + } + + public function test_replacements() { + $this->test_replace_scaled_to_unscaled(); + $this->test_replace_unscaled_to_scaled(); + $this->test_replace_scaled_to_scaled(); + $this->test_replace_unscaled_to_unscaled(); + } + + private function test_replace_scaled_to_unscaled() { + $replace_file = [ + 'name' => 'replace-unscaled.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => self::FILESTASH . 'replace-unscaled.jpg', + ]; + + $this->do_replace_test( self::$scaled_unscaled_id, $replace_file, true, false ); + } + + private function test_replace_unscaled_to_scaled() { + $replace_file = [ + 'name' => 'replace-scaled.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => self::FILESTASH . 'replace-scaled.jpg', + ]; + + $this->do_replace_test( self::$unscaled_scaled_id, $replace_file, false, true ); + } + + private function test_replace_scaled_to_scaled() { + $replace_file = [ + 'name' => 'replace-scaled-alt.jpg', // Using the alt file + 'type' => 'image/jpeg', + 'tmp_name' => self::FILESTASH . 'replace-scaled-alt.jpg', + ]; + + $this->do_replace_test( self::$scaled_scaled_id, $replace_file, true, true ); + } + + private function test_replace_unscaled_to_unscaled() { + $replace_file = [ + 'name' => 'replace-unscaled-alt.jpg', // Using the alt file + 'type' => 'image/jpeg', + 'tmp_name' => self::FILESTASH . 'replace-unscaled-alt.jpg', + ]; + + $this->do_replace_test( self::$unscaled_unscaled_id, $replace_file, false, false ); + } + + private function do_replace_test( $id_to_replace, $replace_file, $source_scaled, $result_scaled ) { + // Removed var_dump + + $model = new Optml_Attachment_Model( $id_to_replace ); + $metadata = $model->get_attachment_metadata(); + + // Store original size for comparison + $original_size = $metadata['filesize']; + + // Assert initial scaled status + $this->assertTrue( $model->is_scaled() === $source_scaled, 'Initial scaled status mismatch.' ); + + // Perform the replacement + $replacer = new Optml_Attachment_Replace( $id_to_replace, $replace_file ); + $result = $replacer->replace(); + $this->assertTrue( $result, 'Replacement operation failed.' ); + + // Get the model and metadata after replacement + $new_model = new Optml_Attachment_Model( $id_to_replace ); + $new_metadata = $new_model->get_attachment_metadata(); + $new_size = $new_metadata['filesize']; + + // Assert final scaled status + $this->assertTrue( $new_model->is_scaled() === $result_scaled, 'Resulting scaled status mismatch.' ); + + // Assert file size changed (assuming replacement files have different sizes) + $this->assertNotEquals( $original_size, $new_size, 'File size did not change after replacement.' ); + } +} diff --git a/tests/media_rename/test-db-renamer.php b/tests/media_rename/test-db-renamer.php new file mode 100644 index 00000000..d98cc731 --- /dev/null +++ b/tests/media_rename/test-db-renamer.php @@ -0,0 +1,323 @@ +<?php +/** + * Test class for Optml_Attachment_Db_Renamer. + */ +// Removed: require_once 'attachment_edit_utils.php'; + +/** + * Class Test_Attachment_Db_Renamer. + */ +class Test_Attachment_Db_Renamer extends WP_UnitTestCase { + // Removed: use Attachment_Edit_Utils; + + const POST_CONTENT = '<!-- wp:image {"id":77,"sizeSlug":"full","linkDestination":"none","align":"center"} --> +<figure class="wp-block-image aligncenter size-full"><img src="http://om-wp.test/wp-content/uploads/2025/03/optimole-logo.svg" alt="" class="wp-image-77"/></figure> +<!-- /wp:image --> + +<!-- wp:video {"id":85} --> +<figure class="wp-block-video"><video controls src="http://om-wp.test/wp-content/uploads/2025/03/video-file.mp4"></video></figure> +<!-- /wp:video --> + +<!-- wp:group {"layout":{"type":"grid","minimumColumnWidth":"19rem"}} --> +<div class="wp-block-group"><!-- wp:image {"id":89,"sizeSlug":"full","linkDestination":"none"} --> +<figure class="wp-block-image size-full"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure> +<!-- /wp:image --> + +<!-- wp:image {"id":89,"sizeSlug":"large","linkDestination":"none"} --> +<figure class="wp-block-image size-large"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-scaled.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure> +<!-- /wp:image --> + +<!-- wp:image {"id":89,"sizeSlug":"medium","linkDestination":"none"} --> +<figure class="wp-block-image size-medium"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-300x200.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure> +<!-- /wp:image --> + +<!-- wp:image {"id":89,"sizeSlug":"thumbnail","linkDestination":"none"} --> +<figure class="wp-block-image size-thumbnail"><img src="http://om-wp.test/wp-content/uploads/2025/03/asdfgh-150x150.jpg" alt="" class="wp-image-89"/><figcaption class="wp-element-caption">Picsum ID: 450</figcaption></figure> +<!-- /wp:image --></div> +<!-- /wp:group -->'; + + protected static $attachment_id; + protected static $attachment_model; + protected static $post_id; + protected static $replacer; + protected static $replace_method; // ReflectionMethod + protected static $replacer_skip_sizes; + protected static $replace_skip_sizes_method; // ReflectionMethod + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$post_id = $factory->post->create( [ + 'post_title' => 'Test Post', + 'post_content' => self::POST_CONTENT, + ] ); + + self::$attachment_id = $factory->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + self::$attachment_model = new Optml_Attachment_Model( self::$attachment_id ); + + self::$replacer = new Optml_Attachment_Db_Renamer(); + self::$replace_method = new ReflectionMethod( 'Optml_Attachment_Db_Renamer', 'replace_urls_in_value' ); + self::$replace_method->setAccessible( true ); + + self::$replacer_skip_sizes = new Optml_Attachment_Db_Renamer( true ); + self::$replace_skip_sizes_method = new ReflectionMethod( 'Optml_Attachment_Db_Renamer', 'replace_urls_in_value' ); + self::$replace_skip_sizes_method->setAccessible( true ); + } + + public static function tear_down_after_class() { + wp_delete_post( self::$attachment_id, true ); + wp_delete_post( self::$post_id, true ); + parent::tear_down_after_class(); + } + + public function test_general() { + $this->assertInstanceOf( 'WP_Post', get_post( self::$attachment_id ) ); + $this->assertInstanceOf( 'Optml_Attachment_Model', self::$attachment_model ); + $this->assertInstanceOf( 'Optml_Attachment_Db_Renamer', self::$replacer ); + } + + /** + * Test replace method with valid URLs + */ + public function test_replace() { + $old_url = self::$attachment_model->get_main_url(); + $new_url = str_replace( 'sample-test', 'new-test-image', $old_url ); + + $count = self::$replacer->replace( $old_url, $new_url ); + $this->assertGreaterThan( 0, $count ); + } + + /** + * Test replace method with identical URLs + */ + public function test_replace_with_identical_urls() { + $url = self::$attachment_model->get_main_url(); + + $count = self::$replacer->replace( $url, $url ); + $this->assertEquals( 0, $count ); + } + + /** + * Test replace method with empty URLs + */ + public function test_replace_with_empty_urls() { + $count = self::$replacer->replace( '', '' ); + $this->assertEquals( 0, $count ); + + $count = self::$replacer->replace( 'http://example.com', '' ); + $this->assertEquals( 0, $count ); + + $count = self::$replacer->replace( '', 'http://example.com' ); + $this->assertEquals( 0, $count ); + } + + /** + * Test replace method with null URLs + */ + public function test_replace_with_null_urls() { + $count = self::$replacer->replace( null, null ); + $this->assertEquals( 0, $count ); + + $count = self::$replacer->replace( 'http://example.com', null ); + $this->assertEquals( 0, $count ); + + $count = self::$replacer->replace( null, 'http://example.com' ); + $this->assertEquals( 0, $count ); + } + + public function test_simple_replacement() { + $value = 'http://example.com'; + $old_url = 'http://example.com'; + $new_url = 'http://example.org'; + + $replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url ); + $this->assertEquals( $new_url, $replaced ); + } + + public function test_multiple_replacement() { + $value = 'http://example.com http://example.com http://example.com'; + $old_url = 'http://example.com'; + $new_url = 'http://example.org'; + + $replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url ); + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $new_url, $replaced ); + } + + public function test_replacing_scaled_urls() { + $value = "<img src=\"http://example.com/wp-content/uploads/2020/01/image-150x150.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image-300x300.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image-scaled.jpg\" /><img src=\"http://example.com/wp-content/uploads/2020/01/image.jpg\" />"; + + $old_url = 'http://example.com/wp-content/uploads/2020/01/image.jpg'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg'; + + $replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url ); + + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $new_url, $replaced ); + + $this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-150x150.jpg', $replaced ); + $this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-300x300.jpg', $replaced ); + $this->assertStringContainsString( 'http://example.com/wp-content/uploads/2020/01/new-url-scaled.jpg', $replaced ); + } + + public function test_static__replacing_serialized_content() { + $array = [ + 'image' => 'http://example.com/wp-content/uploads/2020/01/image.jpg', + 'thumb' => 'http://example.com/wp-content/uploads/2020/01/thumb.jpg', + ]; + $value = serialize( $array ); + // Validate serialized content. + $this->assertCount( 2, unserialize( $value ) ); + + $old_url = 'http://example.com/wp-content/uploads/2020/01/image.jpg'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg'; + + $replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url ); + // Validate serialized content. + $this->assertCount( 2, unserialize( $replaced ) ); + + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $new_url, $replaced ); + $this->assertStringContainsString( 'thumb.jpg', $replaced ); + } + + public function test_static__replacing_in_json() { + $array = [ + 'image' => 'http://example.com/wp-content/uploads/2020/01/image.jpg', + 'thumb' => 'http://example.com/wp-content/uploads/2020/01/thumb.jpg', + ]; + + $value = json_encode( $array ); + + // Validate JSON. + $this->assertCount( 2, json_decode( $value, true ) ); + + $old_url = 'http://example.com/wp-content/uploads/2020/01/image.jpg'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-url.jpg'; + $thumb_url = 'http://example.com/wp-content/uploads/2020/01/thumb.jpg'; + + $expected_new_url = 'http:\/\/example.com\/wp-content\/uploads\/2020\/01\/new-url.jpg'; + + $replaced = self::$replace_method->invoke( self::$replacer, $value, $old_url, $new_url ); + // Validate JSON. + $this->assertCount( 2, json_decode( $replaced, true ) ); + + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $expected_new_url, $replaced ); + $this->assertStringContainsString( 'thumb.jpg', $replaced ); + $this->assertEquals( 1, substr_count( $replaced, $expected_new_url ) ); + + $replaced_2 = self::$replace_method->invoke( self::$replacer, $replaced, $thumb_url, $new_url ); + // Validate JSON. + $this->assertCount( 2, json_decode( $replaced_2, true ) ); + + $this->assertStringNotContainsString( $thumb_url, $replaced_2 ); + $this->assertStringContainsString( $expected_new_url, $replaced_2 ); + $this->assertEquals( 2, substr_count( $replaced_2, $expected_new_url ) ); + } + + public function test_static__skip_sizes() { + $content = self::POST_CONTENT; + + $old_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg'; + $new_url = 'http://om-wp.test/wp-content/uploads/2025/03/new-asdfgh.jpg'; + + $not_removed = [ + 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-scaled.jpg', + 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-300x200.jpg', + 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh-150x150.jpg', + ]; + + $replaced = self::$replace_skip_sizes_method->invoke( self::$replacer_skip_sizes, $content, $old_url, $new_url ); + + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $new_url, $replaced ); + + foreach ( $not_removed as $url ) { + $this->assertStringContainsString( $url, $replaced ); + } + } + + public function test_static__ensure_sizes() { + $content = self::POST_CONTENT; + + $old_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg'; + $new_url = 'http://om-wp.test/wp-content/uploads/2025/03/properly_named_image.jpg'; + + $replaced = self::$replace_method->invoke( self::$replacer, $content, $old_url, $new_url ); + + $this->assertStringNotContainsString( $old_url, $replaced ); + $this->assertStringContainsString( $new_url, $replaced ); + + $should_exist = [ + 'properly_named_image-scaled.jpg', + 'properly_named_image-300x200.jpg', + 'properly_named_image-150x150.jpg', + ]; + + $should_not_exist = [ + 'asdfgh-scaled.jpg', + 'asdfgh-300x200.jpg', + 'asdfgh-150x150.jpg', + ]; + + foreach ( $should_exist as $url ) { + $this->assertStringContainsString( $url, $replaced ); + } + + foreach ( $should_not_exist as $url ) { + $this->assertStringNotContainsString( $url, $replaced ); + } + } + + public function test_db__post_content_video_replacement() { + $initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/video-file.mp4'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-home-made-tutorial.mp4'; + + self::$replacer->replace( $initial_url, $new_url ); + + $post = get_post( self::$post_id ); + + $this->assertStringContainsString( $new_url, $post->post_content ); + $this->assertStringNotContainsString( $initial_url, $post->post_content ); + } + + public function test_db__svg_replacement() { + $initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/optimole-logo.svg'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-logo.svg'; + + self::$replacer->replace( $initial_url, $new_url ); + + $post = get_post( self::$post_id ); + + $this->assertStringContainsString( $new_url, $post->post_content ); + $this->assertStringNotContainsString( $initial_url, $post->post_content ); + } + + public function test_db__post_content_sizes_replacement() { + $initial_url = 'http://om-wp.test/wp-content/uploads/2025/03/asdfgh.jpg'; + $new_url = 'http://example.com/wp-content/uploads/2020/01/new-proper_image.jpg'; + + self::$replacer->replace( $initial_url, $new_url ); + + $post = get_post( self::$post_id ); + + $should_exist = [ + 'new-proper_image-scaled.jpg', + 'new-proper_image-300x200.jpg', + 'new-proper_image-150x150.jpg', + ]; + + $should_not_exist = [ + 'asdfgh-scaled.jpg', + 'asdfgh-300x200.jpg', + 'asdfgh-150x150.jpg', + ]; + + foreach ( $should_exist as $url ) { + $this->assertStringContainsString( $url, $post->post_content ); + } + + foreach ( $should_not_exist as $url ) { + $this->assertStringNotContainsString( $url, $post->post_content ); + } + } +}