diff --git a/inc/duplication/data.php b/inc/duplication/data.php
index 2c994d00b..17e2309e6 100644
--- a/inc/duplication/data.php
+++ b/inc/duplication/data.php
@@ -229,13 +229,30 @@ public static function db_update_data($from_site_id, $to_site_id, $saved_options
$from_site_prefix = $wpdb->get_blog_prefix($from_site_id);
$to_site_prefix = $wpdb->get_blog_prefix($to_site_id);
+ $from_upload_url_clean = wu_replace_scheme($from_upload_url);
+ $to_upload_url_clean = wu_replace_scheme($to_upload_url);
+ $from_upload_url_w_network_clean = wu_replace_scheme($from_upload_url_w_network);
+ $to_upload_url_w_network_clean = wu_replace_scheme($to_upload_url_w_network);
+ $from_blog_url_clean = wu_replace_scheme($from_blog_url);
+ $to_blog_url_clean = wu_replace_scheme($to_blog_url);
+
$string_to_replace = [
- wu_replace_scheme($from_upload_url) => wu_replace_scheme($to_upload_url),
- wu_replace_scheme($from_upload_url_w_network) => wu_replace_scheme($to_upload_url_w_network),
- wu_replace_scheme($from_blog_url) => wu_replace_scheme($to_blog_url),
- $from_site_prefix => $to_site_prefix,
+ $from_upload_url_clean => $to_upload_url_clean,
+ $from_upload_url_w_network_clean => $to_upload_url_w_network_clean,
+ $from_blog_url_clean => $to_blog_url_clean,
+ $from_site_prefix => $to_site_prefix,
+ ];
+
+ // Add JSON-escaped versions of URLs (forward slashes escaped as \/).
+ // Page builders like Elementor store URLs in JSON where / becomes \/.
+ $json_replacements = [
+ str_replace('/', '\\/', $from_upload_url_clean) => str_replace('/', '\\/', $to_upload_url_clean),
+ str_replace('/', '\\/', $from_upload_url_w_network_clean) => str_replace('/', '\\/', $to_upload_url_w_network_clean),
+ str_replace('/', '\\/', $from_blog_url_clean) => str_replace('/', '\\/', $to_blog_url_clean),
];
+ $string_to_replace = array_merge($string_to_replace, $json_replacements);
+
$string_to_replace = apply_filters('mucd_string_to_replace', $string_to_replace, $from_site_id, $to_site_id);
foreach ( $tables as $table => $field) {
@@ -313,7 +330,6 @@ public static function update($table, $fields, $from_string, $to_string): void {
/**
* Replace $from_string with $to_string in $val
- * Warning : if $to_string already in $val, no replacement is made
*
* @since 0.2.0
* @param string $val Original value to modify.
@@ -322,15 +338,11 @@ public static function update($table, $fields, $from_string, $to_string): void {
* @return string The new string.
*/
public static function replace($val, $from_string, $to_string) {
- $new = $val;
if (is_string($val)) {
- $pos = strpos($val, $to_string);
- if (false === $pos) {
- $new = str_replace($from_string, $to_string, $val);
- }
+ return str_replace($from_string, $to_string, $val);
}
- return $new;
+ return $val;
}
/**
@@ -402,6 +414,18 @@ public static function try_replace($row, $field, $from_string, $to_string) {
if ($double_serialize) {
$row[ $field ] = serialize($row[ $field ]);
}
+ } elseif (is_array($row[ $field ])) {
+ $row[ $field ] = self::replace_recursive($row[ $field ], $from_string, $to_string);
+ } elseif (is_object($row[ $field ])) {
+ $array_object = (array) $row[ $field ];
+ $array_object = self::replace_recursive($array_object, $from_string, $to_string);
+ foreach ($array_object as $key => $value) {
+ try {
+ $row[ $field ]->$key = $value;
+ } catch (\Throwable $exception) {
+ // ...nothing
+ }
+ }
} else {
$row[ $field ] = self::replace($row[ $field ], $from_string, $to_string);
}
diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php
new file mode 100644
index 000000000..c259f95f6
--- /dev/null
+++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php
@@ -0,0 +1,287 @@
+assertEquals('Visit example.com/new-site for details', $result);
+ }
+
+ /**
+ * Test replacement works when to_string already exists in value.
+ *
+ * This was the bug: the old code skipped replacement if $to_string
+ * was found anywhere in $val, which broke subdirectory multisite
+ * where upload URL replacement happened first.
+ */
+ public function test_replace_when_to_string_already_present() {
+ // Simulate: upload URL was already replaced, now blog URL replacement runs.
+ // Value already contains "example.com/new-site" from previous replacement,
+ // but still has "example.com/old-site" in button URLs.
+ $val = 'https://example.com/new-site/wp-content/uploads/image.jpg and https://example.com/old-site/contact';
+
+ $result = \MUCD_Data::replace(
+ $val,
+ 'example.com/old-site',
+ 'example.com/new-site'
+ );
+
+ $this->assertEquals(
+ 'https://example.com/new-site/wp-content/uploads/image.jpg and https://example.com/new-site/contact',
+ $result
+ );
+ }
+
+ /**
+ * Test replacement with multiple occurrences of from_string.
+ */
+ public function test_replace_multiple_occurrences() {
+ $val = 'Link: example.com/old and another: example.com/old/page';
+
+ $result = \MUCD_Data::replace($val, 'example.com/old', 'example.com/new');
+
+ $this->assertEquals('Link: example.com/new and another: example.com/new/page', $result);
+ }
+
+ /**
+ * Test replacement with non-string value.
+ */
+ public function test_replace_non_string_returns_unchanged() {
+ $this->assertEquals(42, \MUCD_Data::replace(42, 'foo', 'bar'));
+ $this->assertNull(\MUCD_Data::replace(null, 'foo', 'bar'));
+ $this->assertTrue(\MUCD_Data::replace(true, 'foo', 'bar'));
+ }
+
+ /**
+ * Test replacement when from_string is not found.
+ */
+ public function test_replace_no_match() {
+ $val = 'No matching content here';
+ $result = \MUCD_Data::replace($val, 'example.com/old', 'example.com/new');
+
+ $this->assertEquals($val, $result);
+ }
+
+ /**
+ * Test try_replace with serialized array data.
+ */
+ public function test_try_replace_serialized_array() {
+ $data = serialize(['url' => 'https://example.com/old-site/page']);
+ $row = ['meta_value' => $data];
+
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
+
+ $unserialized = unserialize($result);
+ $this->assertEquals('https://example.com/new-site/page', $unserialized['url']);
+ }
+
+ /**
+ * Test try_replace with nested serialized data.
+ */
+ public function test_try_replace_nested_serialized_array() {
+ $data = serialize([
+ 'settings' => [
+ 'link' => 'https://example.com/old-site/about',
+ 'icon' => 'fa-home',
+ ],
+ 'content' => 'Visit https://example.com/old-site for more',
+ ]);
+ $row = ['meta_value' => $data];
+
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
+ $unserialized = unserialize($result);
+
+ $this->assertEquals('https://example.com/new-site/about', $unserialized['settings']['link']);
+ $this->assertEquals('Visit https://example.com/new-site for more', $unserialized['content']);
+ $this->assertEquals('fa-home', $unserialized['settings']['icon']);
+ }
+
+ /**
+ * Test try_replace with plain (non-serialized) string.
+ */
+ public function test_try_replace_plain_string() {
+ $row = ['post_content' => 'Link'];
+
+ $result = \MUCD_Data::try_replace($row, 'post_content', 'example.com/old-site', 'example.com/new-site');
+
+ $this->assertEquals('Link', $result);
+ }
+
+ /**
+ * Test try_replace with double-serialized data.
+ */
+ public function test_try_replace_double_serialized() {
+ $data = serialize(serialize(['url' => 'https://example.com/old-site/page']));
+ $row = ['meta_value' => $data];
+
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
+
+ $unserialized = unserialize(unserialize($result));
+ $this->assertEquals('https://example.com/new-site/page', $unserialized['url']);
+ }
+
+ /**
+ * Test try_replace with serialized data containing the to_string already.
+ *
+ * Ensures the fix works: even when to_string is present, remaining
+ * from_string occurrences should still be replaced.
+ */
+ public function test_try_replace_serialized_with_partial_replacement() {
+ $data = serialize([
+ 'upload_url' => 'https://example.com/new-site/wp-content/uploads/img.jpg',
+ 'button_url' => 'https://example.com/old-site/contact',
+ 'page_url' => 'https://example.com/old-site/about',
+ ]);
+ $row = ['meta_value' => $data];
+
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
+ $unserialized = unserialize($result);
+
+ $this->assertEquals('https://example.com/new-site/wp-content/uploads/img.jpg', $unserialized['upload_url']);
+ $this->assertEquals('https://example.com/new-site/contact', $unserialized['button_url']);
+ $this->assertEquals('https://example.com/new-site/about', $unserialized['page_url']);
+ }
+
+ /**
+ * Test try_replace with Elementor-like JSON data stored in postmeta.
+ *
+ * JSON encodes forward slashes as \/, so the plain URL replacement won't
+ * match. The system handles this by running a separate pass with
+ * JSON-escaped search/replace strings at the DB level.
+ */
+ public function test_try_replace_elementor_json_data() {
+ $elementor_data = json_encode([
+ [
+ 'elType' => 'section',
+ 'settings' => [],
+ 'elements' => [
+ [
+ 'elType' => 'widget',
+ 'settings' => [
+ 'link' => [
+ 'url' => 'https://example.com/old-site/services',
+ ],
+ 'button_link' => [
+ 'url' => 'https://example.com/old-site/contact-us',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ // Elementor stores _elementor_data as a plain JSON string (not serialized).
+ $row = ['meta_value' => $elementor_data];
+
+ // First pass: plain URL replacement (won't match JSON-escaped slashes).
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
+
+ // Second pass: JSON-escaped URL replacement (matches \/ in JSON).
+ $row = ['meta_value' => $result];
+ $result = \MUCD_Data::try_replace(
+ $row,
+ 'meta_value',
+ str_replace('/', '\\/', 'example.com/old-site'),
+ str_replace('/', '\\/', 'example.com/new-site')
+ );
+
+ $this->assertStringNotContainsString('old-site', $result);
+ $this->assertStringContainsString('new-site\\/services', $result);
+ $this->assertStringContainsString('new-site\\/contact-us', $result);
+ }
+
+ /**
+ * Test replace with JSON-escaped forward slashes.
+ */
+ public function test_replace_json_escaped_slashes() {
+ // JSON-encoded URL: forward slashes become \/
+ $val = 'https:\\/\\/example.com\\/old-site\\/page';
+
+ $result = \MUCD_Data::replace(
+ $val,
+ str_replace('/', '\\/', 'example.com/old-site'),
+ str_replace('/', '\\/', 'example.com/new-site')
+ );
+
+ $this->assertEquals('https:\\/\\/example.com\\/new-site\\/page', $result);
+ }
+
+ /**
+ * Test replace_recursive with nested array.
+ */
+ public function test_replace_recursive_nested_array() {
+ $data = [
+ 'level1' => [
+ 'level2' => [
+ 'url' => 'https://example.com/old/deep-page',
+ ],
+ ],
+ 'flat' => 'https://example.com/old/flat-page',
+ ];
+
+ $result = \MUCD_Data::replace_recursive($data, 'example.com/old', 'example.com/new');
+
+ $this->assertEquals('https://example.com/new/deep-page', $result['level1']['level2']['url']);
+ $this->assertEquals('https://example.com/new/flat-page', $result['flat']);
+ }
+
+ /**
+ * Test replace_recursive with mixed types.
+ */
+ public function test_replace_recursive_mixed_types() {
+ $data = [
+ 'string' => 'https://example.com/old/page',
+ 'int' => 42,
+ 'bool' => true,
+ 'null' => null,
+ 'array' => ['https://example.com/old/nested'],
+ ];
+
+ $result = \MUCD_Data::replace_recursive($data, 'example.com/old', 'example.com/new');
+
+ $this->assertEquals('https://example.com/new/page', $result['string']);
+ $this->assertEquals(42, $result['int']);
+ $this->assertTrue($result['bool']);
+ $this->assertNull($result['null']);
+ $this->assertEquals('https://example.com/new/nested', $result['array'][0]);
+ }
+
+ /**
+ * Test serialized data preserves string lengths correctly after replacement.
+ */
+ public function test_try_replace_serialized_preserves_length() {
+ $data = serialize(['url' => 'https://example.com/short/page']);
+ $row = ['meta_value' => $data];
+
+ $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/short', 'example.com/much-longer-path');
+
+ // Should be valid serialized data.
+ $unserialized = @unserialize($result);
+ $this->assertIsArray($unserialized);
+ $this->assertEquals('https://example.com/much-longer-path/page', $unserialized['url']);
+ }
+}