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 ? "
" : "";
+ html += "" + file.name + "";
+
+ $(".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 @@
+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 @@
+ 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' => '
',
+ ];
+
+ 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 .= '';
+
+ $html .= '';
+
+ 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 = '';
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Get the footer HTML.
+ *
+ * @return string The HTML.
+ */
+ private function get_footer_html() {
+ $html = '';
+ $html .= '
';
+ $html .= '

';
+ // translators: %s is the 'Optimole'.
+ $html .= '
' . sprintf( __( 'Powered by %s', 'optimole-wp' ), 'Optimole' ) . '';
+ $html .= '
';
+
+ 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 '
';
+ }
+}
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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+ '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 @@
+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 @@
+ 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 @@
+
+

+
+
+
+
+
+
+
+
+
Picsum ID: 450
+
+
+
+
Picsum ID: 450
+
+
+
+
Picsum ID: 450
+
+
+
+
Picsum ID: 450
+
+';
+
+ 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 = "




";
+
+ $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 );
+ }
+ }
+}