-
-
Notifications
You must be signed in to change notification settings - Fork 75
GH#820: backfill postmeta for nav_menu_item, attachment, and Elementor posts on site clone #825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9b2adaa
6c877ea
46e366a
3f8d25a
dd3461d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -253,6 +253,48 @@ protected static function process_duplication($args) { | |
|
|
||
| \MUCD_Data::copy_data($args->from_site_id, $args->to_site_id); | ||
|
|
||
| /* | ||
| * Resolve the real template source from wu_template_id site meta. | ||
| * | ||
| * MUCD's hooks pass a from_site_id that may differ from the template | ||
| * the customer actually selected at checkout. WP Ultimo stores the | ||
| * customer's real choice in the wu_template_id site meta key. | ||
| * Prefer that over the explicit param when available. | ||
| * | ||
| * @since 2.3.1 | ||
| * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820 | ||
| */ | ||
| $meta_template = (int) get_site_meta($args->to_site_id, 'wu_template_id', true); | ||
| if ($meta_template > 0 && $meta_template !== (int) $args->from_site_id) { | ||
| $args->from_site_id = $meta_template; | ||
| } | ||
|
|
||
| /* | ||
| * Backfill postmeta that MUCD_Data::copy_data() misses. | ||
| * | ||
| * MUCD copies table data with INSERT ... SELECT (full-table copy), but | ||
| * certain post types end up with missing postmeta rows — particularly | ||
| * nav_menu_item, attachment, and elementor_library posts. The Elementor | ||
| * Kit post (usually ID 3) also gets stub postmeta that must be | ||
| * overwritten with the real template values. | ||
| * | ||
| * @since 2.3.1 | ||
| * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820 | ||
| */ | ||
| self::backfill_postmeta($args->from_site_id, $args->to_site_id); | ||
|
|
||
| /* | ||
| * Verify Kit integrity after backfill. | ||
| * | ||
| * Compares the byte length of _elementor_page_settings between the | ||
| * template and the clone. If the clone has less than 80% of the | ||
| * template's byte count, the Kit fix is re-applied as a safety net. | ||
| * | ||
| * @since 2.3.1 | ||
| * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820 | ||
| */ | ||
| self::verify_kit_integrity($args->from_site_id, $args->to_site_id); | ||
|
Comment on lines
+284
to
+296
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Backfilled postmeta is bypassing MUCD's rewrite step. These copies happen after Also applies to: 381-385, 424-586 🤖 Prompt for AI Agents |
||
|
|
||
| if ($args->keep_users) { | ||
| \MUCD_Duplicate::copy_users($args->from_site_id, $args->to_site_id); | ||
| } | ||
|
|
@@ -274,7 +316,8 @@ protected static function process_duplication($args) { | |
| do_action( | ||
| 'wu_duplicate_site', | ||
| [ | ||
| 'site_id' => $args->to_site_id, | ||
| 'from_site_id' => $args->from_site_id, | ||
| 'site_id' => $args->to_site_id, | ||
| ] | ||
| ); | ||
|
|
||
|
|
@@ -311,4 +354,282 @@ public static function create_admin($email, $domain) { | |
|
|
||
| return $user_id; | ||
| } | ||
|
|
||
| /** | ||
| * Backfill postmeta rows that MUCD_Data::copy_data() misses. | ||
| * | ||
| * MUCD copies table data with INSERT ... SELECT, but certain post types | ||
| * end up with missing or stub postmeta rows. This method fills the gaps | ||
| * for nav_menu_item, attachment, and Elementor post types, and force- | ||
| * overwrites the Elementor Kit settings which MUCD inserts as stubs. | ||
| * | ||
| * @since 2.3.1 | ||
| * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820 | ||
| * | ||
| * @param int $from_site_id Source (template) blog ID. | ||
| * @param int $to_site_id Target (cloned) blog ID. | ||
| */ | ||
| protected static function backfill_postmeta($from_site_id, $to_site_id) { | ||
|
|
||
| $from_site_id = (int) $from_site_id; | ||
| $to_site_id = (int) $to_site_id; | ||
|
|
||
| if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) { | ||
| return; | ||
| } | ||
|
|
||
| self::backfill_nav_menu_postmeta($from_site_id, $to_site_id); | ||
| self::backfill_attachment_postmeta($from_site_id, $to_site_id); | ||
| self::backfill_elementor_postmeta($from_site_id, $to_site_id); | ||
| self::backfill_kit_settings($from_site_id, $to_site_id); | ||
| } | ||
|
|
||
| /** | ||
| * Backfill nav_menu_item postmeta from template to cloned site. | ||
| * | ||
| * MUCD copies nav_menu_item posts (preserving IDs) but not their postmeta | ||
| * rows. Without these rows, menus render as empty list items with no | ||
| * titles, URLs, or parent relationships. | ||
| * | ||
| * @since 2.3.1 | ||
| * | ||
| * @param int $from_site_id Source blog ID. | ||
| * @param int $to_site_id Target blog ID. | ||
| */ | ||
| protected static function backfill_nav_menu_postmeta($from_site_id, $to_site_id) { | ||
|
|
||
| global $wpdb; | ||
|
|
||
| $from_prefix = $wpdb->get_blog_prefix($from_site_id); | ||
| $to_prefix = $wpdb->get_blog_prefix($to_site_id); | ||
|
|
||
| if ($from_prefix === $to_prefix) { | ||
| return; | ||
| } | ||
|
|
||
| $meta_keys = [ | ||
| '_menu_item_type', | ||
| '_menu_item_menu_item_parent', | ||
| '_menu_item_object_id', | ||
| '_menu_item_object', | ||
| '_menu_item_target', | ||
| '_menu_item_classes', | ||
| '_menu_item_xfn', | ||
| '_menu_item_url', | ||
| ]; | ||
|
|
||
| $placeholders = implode(',', array_fill(0, count($meta_keys), '%s')); | ||
|
|
||
| // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| $wpdb->query( | ||
| $wpdb->prepare( | ||
| "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value) | ||
| SELECT src.post_id, src.meta_key, src.meta_value | ||
| FROM {$from_prefix}postmeta src | ||
| INNER JOIN {$to_prefix}posts tgt | ||
| ON tgt.ID = src.post_id | ||
| AND tgt.post_type = 'nav_menu_item' | ||
| WHERE src.meta_key IN ({$placeholders}) | ||
| AND NOT EXISTS ( | ||
| SELECT 1 FROM {$to_prefix}postmeta tpm | ||
| WHERE tpm.post_id = src.post_id | ||
| AND tpm.meta_key = src.meta_key | ||
| )", | ||
| ...$meta_keys | ||
| ) | ||
| ); | ||
| // phpcs:enable | ||
| } | ||
|
|
||
| /** | ||
| * Backfill attachment postmeta from template to cloned site. | ||
| * | ||
| * MUCD copies attachment posts but not their postmeta. Without | ||
| * _wp_attached_file, wp_get_attachment_image_url() returns false and | ||
| * images disappear even though the physical files exist on disk. | ||
| * | ||
| * @since 2.3.1 | ||
| * | ||
| * @param int $from_site_id Source blog ID. | ||
| * @param int $to_site_id Target blog ID. | ||
| */ | ||
| protected static function backfill_attachment_postmeta($from_site_id, $to_site_id) { | ||
|
|
||
| global $wpdb; | ||
|
|
||
| $from_prefix = $wpdb->get_blog_prefix($from_site_id); | ||
| $to_prefix = $wpdb->get_blog_prefix($to_site_id); | ||
|
|
||
| if ($from_prefix === $to_prefix) { | ||
| return; | ||
| } | ||
|
|
||
| // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| $wpdb->query( | ||
| "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value) | ||
| SELECT src.post_id, src.meta_key, src.meta_value | ||
| FROM {$from_prefix}postmeta src | ||
| INNER JOIN {$to_prefix}posts tgt | ||
| ON tgt.ID = src.post_id | ||
| AND tgt.post_type = 'attachment' | ||
| WHERE NOT EXISTS ( | ||
| SELECT 1 FROM {$to_prefix}postmeta tpm | ||
| WHERE tpm.post_id = src.post_id | ||
| AND tpm.meta_key = src.meta_key | ||
| )" | ||
| ); | ||
| // phpcs:enable | ||
| } | ||
|
|
||
| /** | ||
| * Backfill Elementor postmeta for all post types. | ||
| * | ||
| * Catch-all for any _elementor_* meta that MUCD missed. Covers | ||
| * elementor_library (headers, footers, popups), e-landing-page, | ||
| * elementor_snippet, and any custom post type with Elementor data. | ||
| * | ||
| * @since 2.3.1 | ||
| * | ||
| * @param int $from_site_id Source blog ID. | ||
| * @param int $to_site_id Target blog ID. | ||
| */ | ||
| protected static function backfill_elementor_postmeta($from_site_id, $to_site_id) { | ||
|
|
||
| global $wpdb; | ||
|
|
||
| $from_prefix = $wpdb->get_blog_prefix($from_site_id); | ||
| $to_prefix = $wpdb->get_blog_prefix($to_site_id); | ||
|
|
||
| if ($from_prefix === $to_prefix) { | ||
| return; | ||
| } | ||
|
|
||
| $like_pattern = $wpdb->esc_like('_elementor') . '%'; | ||
|
|
||
| // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared | ||
| $wpdb->query( | ||
| $wpdb->prepare( | ||
| "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value) | ||
| SELECT src.post_id, src.meta_key, src.meta_value | ||
| FROM {$from_prefix}postmeta src | ||
| INNER JOIN {$to_prefix}posts tgt | ||
| ON tgt.ID = src.post_id | ||
| WHERE src.meta_key LIKE %s | ||
| AND NOT EXISTS ( | ||
| SELECT 1 FROM {$to_prefix}postmeta tpm | ||
| WHERE tpm.post_id = src.post_id | ||
| AND tpm.meta_key = src.meta_key | ||
| )", | ||
| $like_pattern | ||
| ) | ||
| ); | ||
| // phpcs:enable | ||
| } | ||
|
|
||
| /** | ||
| * Force-overwrite the Elementor Kit settings on the cloned site. | ||
| * | ||
| * The Kit post (holding colors, typography, logo) gets created with stub | ||
| * Elementor defaults BEFORE MUCD runs its INSERT ... SELECT. Because MUCD | ||
| * uses INSERT NOT EXISTS, the stub row is never overwritten, leaving the | ||
| * clone with default Elementor colors instead of the template palette. | ||
| * | ||
| * This method reads the real settings from the template and uses | ||
| * update_post_meta() to guarantee the overwrite. | ||
| * | ||
| * @since 2.3.1 | ||
| * | ||
| * @param int $from_site_id Source blog ID. | ||
| * @param int $to_site_id Target blog ID. | ||
| */ | ||
| protected static function backfill_kit_settings($from_site_id, $to_site_id) { | ||
|
|
||
| // Read kit settings from the template site. | ||
| switch_to_blog($from_site_id); | ||
|
|
||
| $kit_id_from = (int) get_option('elementor_active_kit', 0); | ||
| $kit_settings = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_page_settings', true) : ''; | ||
| $kit_data = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_data', true) : ''; | ||
|
|
||
| restore_current_blog(); | ||
|
|
||
| if (empty($kit_settings)) { | ||
| return; | ||
| } | ||
|
|
||
| // Force-overwrite kit settings on the target site. | ||
| // Uses update_post_meta() instead of INSERT NOT EXISTS because | ||
| // the target kit may already have stub metadata from Elementor's | ||
| // activation routine. INSERT NOT EXISTS would silently skip the | ||
| // row, leaving the clone with default Elementor colors. | ||
| switch_to_blog($to_site_id); | ||
|
|
||
| $kit_id_to = (int) get_option('elementor_active_kit', 0); | ||
|
|
||
| if ( ! $kit_id_to && $kit_id_from) { | ||
| $kit_id_to = $kit_id_from; | ||
| update_option('elementor_active_kit', $kit_id_to); | ||
| } | ||
|
|
||
| if ($kit_id_to) { | ||
| update_post_meta($kit_id_to, '_elementor_page_settings', $kit_settings); | ||
|
|
||
| if ( ! empty($kit_data) && '[]' !== $kit_data) { | ||
| update_post_meta($kit_id_to, '_elementor_data', $kit_data); | ||
| } | ||
|
|
||
| // Clear compiled CSS so Elementor_Compat::regenerate_css() will | ||
| // rebuild with the correct Kit settings on wu_duplicate_site. | ||
| delete_post_meta($kit_id_to, '_elementor_css'); | ||
| } | ||
|
|
||
| restore_current_blog(); | ||
| } | ||
|
|
||
| /** | ||
| * Verify Kit integrity after clone and re-apply if mismatched. | ||
| * | ||
| * Compares the byte length of _elementor_page_settings between the | ||
| * template and the clone. If the clone has less than 80% of the | ||
| * template's byte count, the Kit fix is re-applied as a safety net. | ||
| * | ||
| * This catches edge cases where update_post_meta() succeeded but the | ||
| * stored value was truncated by a concurrent write, or where Elementor's | ||
| * activation routine overwrote the Kit settings after backfill. | ||
| * | ||
| * @since 2.3.1 | ||
| * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820 | ||
| * | ||
| * @param int $from_site_id Source blog ID. | ||
| * @param int $to_site_id Target blog ID. | ||
| */ | ||
| protected static function verify_kit_integrity($from_site_id, $to_site_id) { | ||
|
|
||
| $from_site_id = (int) $from_site_id; | ||
| $to_site_id = (int) $to_site_id; | ||
|
|
||
| if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) { | ||
| return; | ||
| } | ||
|
|
||
| switch_to_blog($from_site_id); | ||
| $kit_id_from = (int) get_option('elementor_active_kit', 0); | ||
| $from_size = $kit_id_from ? strlen(maybe_serialize(get_post_meta($kit_id_from, '_elementor_page_settings', true))) : 0; | ||
| restore_current_blog(); | ||
|
|
||
| switch_to_blog($to_site_id); | ||
| $kit_id_to = (int) get_option('elementor_active_kit', 0); | ||
| $to_size = $kit_id_to ? strlen(maybe_serialize(get_post_meta($kit_id_to, '_elementor_page_settings', true))) : 0; | ||
| restore_current_blog(); | ||
|
|
||
| if ( ! $from_size || ! $to_size) { | ||
| return; | ||
| } | ||
|
|
||
| // If the clone has less than 80% of the template's byte count, | ||
| // the Kit settings are likely incomplete — re-apply the fix. | ||
| if ($to_size < ($from_size * 0.8)) { | ||
| self::backfill_kit_settings($from_site_id, $to_site_id); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Don't overwrite
from_site_idin place aftercopy_data().copy_data()andcopy_files()have already run with the original source by this point, butcopy_users()and thewu_duplicate_sitepayload now read the rewritten value. Ifwu_template_iddiffers, the clone can mix tables/files from one site with users and downstream hook context from another. Keep the resolved template ID in a separate variable for the backfill/hook path instead of mutating$args->from_site_id.💡 Proposed direction
Also applies to: 298-320
🤖 Prompt for AI Agents