@@ -253,6 +253,48 @@ protected static function process_duplication($args) {
253253
254254 \MUCD_Data::copy_data ($ args ->from_site_id , $ args ->to_site_id );
255255
256+ /*
257+ * Resolve the real template source from wu_template_id site meta.
258+ *
259+ * MUCD's hooks pass a from_site_id that may differ from the template
260+ * the customer actually selected at checkout. WP Ultimo stores the
261+ * customer's real choice in the wu_template_id site meta key.
262+ * Prefer that over the explicit param when available.
263+ *
264+ * @since 2.3.1
265+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
266+ */
267+ $ meta_template = (int ) get_site_meta ($ args ->to_site_id , 'wu_template_id ' , true );
268+ if ($ meta_template > 0 && $ meta_template !== (int ) $ args ->from_site_id ) {
269+ $ args ->from_site_id = $ meta_template ;
270+ }
271+
272+ /*
273+ * Backfill postmeta that MUCD_Data::copy_data() misses.
274+ *
275+ * MUCD copies table data with INSERT ... SELECT (full-table copy), but
276+ * certain post types end up with missing postmeta rows — particularly
277+ * nav_menu_item, attachment, and elementor_library posts. The Elementor
278+ * Kit post (usually ID 3) also gets stub postmeta that must be
279+ * overwritten with the real template values.
280+ *
281+ * @since 2.3.1
282+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
283+ */
284+ self ::backfill_postmeta ($ args ->from_site_id , $ args ->to_site_id );
285+
286+ /*
287+ * Verify Kit integrity after backfill.
288+ *
289+ * Compares the byte length of _elementor_page_settings between the
290+ * template and the clone. If the clone has less than 80% of the
291+ * template's byte count, the Kit fix is re-applied as a safety net.
292+ *
293+ * @since 2.3.1
294+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
295+ */
296+ self ::verify_kit_integrity ($ args ->from_site_id , $ args ->to_site_id );
297+
256298 if ($ args ->keep_users ) {
257299 \MUCD_Duplicate::copy_users ($ args ->from_site_id , $ args ->to_site_id );
258300 }
@@ -274,7 +316,8 @@ protected static function process_duplication($args) {
274316 do_action (
275317 'wu_duplicate_site ' ,
276318 [
277- 'site_id ' => $ args ->to_site_id ,
319+ 'from_site_id ' => $ args ->from_site_id ,
320+ 'site_id ' => $ args ->to_site_id ,
278321 ]
279322 );
280323
@@ -311,4 +354,282 @@ public static function create_admin($email, $domain) {
311354
312355 return $ user_id ;
313356 }
357+
358+ /**
359+ * Backfill postmeta rows that MUCD_Data::copy_data() misses.
360+ *
361+ * MUCD copies table data with INSERT ... SELECT, but certain post types
362+ * end up with missing or stub postmeta rows. This method fills the gaps
363+ * for nav_menu_item, attachment, and Elementor post types, and force-
364+ * overwrites the Elementor Kit settings which MUCD inserts as stubs.
365+ *
366+ * @since 2.3.1
367+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
368+ *
369+ * @param int $from_site_id Source (template) blog ID.
370+ * @param int $to_site_id Target (cloned) blog ID.
371+ */
372+ protected static function backfill_postmeta ($ from_site_id , $ to_site_id ) {
373+
374+ $ from_site_id = (int ) $ from_site_id ;
375+ $ to_site_id = (int ) $ to_site_id ;
376+
377+ if ( ! $ from_site_id || ! $ to_site_id || $ from_site_id === $ to_site_id ) {
378+ return ;
379+ }
380+
381+ self ::backfill_nav_menu_postmeta ($ from_site_id , $ to_site_id );
382+ self ::backfill_attachment_postmeta ($ from_site_id , $ to_site_id );
383+ self ::backfill_elementor_postmeta ($ from_site_id , $ to_site_id );
384+ self ::backfill_kit_settings ($ from_site_id , $ to_site_id );
385+ }
386+
387+ /**
388+ * Backfill nav_menu_item postmeta from template to cloned site.
389+ *
390+ * MUCD copies nav_menu_item posts (preserving IDs) but not their postmeta
391+ * rows. Without these rows, menus render as empty list items with no
392+ * titles, URLs, or parent relationships.
393+ *
394+ * @since 2.3.1
395+ *
396+ * @param int $from_site_id Source blog ID.
397+ * @param int $to_site_id Target blog ID.
398+ */
399+ protected static function backfill_nav_menu_postmeta ($ from_site_id , $ to_site_id ) {
400+
401+ global $ wpdb ;
402+
403+ $ from_prefix = $ wpdb ->get_blog_prefix ($ from_site_id );
404+ $ to_prefix = $ wpdb ->get_blog_prefix ($ to_site_id );
405+
406+ if ($ from_prefix === $ to_prefix ) {
407+ return ;
408+ }
409+
410+ $ meta_keys = [
411+ '_menu_item_type ' ,
412+ '_menu_item_menu_item_parent ' ,
413+ '_menu_item_object_id ' ,
414+ '_menu_item_object ' ,
415+ '_menu_item_target ' ,
416+ '_menu_item_classes ' ,
417+ '_menu_item_xfn ' ,
418+ '_menu_item_url ' ,
419+ ];
420+
421+ $ placeholders = implode (', ' , array_fill (0 , count ($ meta_keys ), '%s ' ));
422+
423+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
424+ $ wpdb ->query (
425+ $ wpdb ->prepare (
426+ "INSERT INTO {$ to_prefix }postmeta (post_id, meta_key, meta_value)
427+ SELECT src.post_id, src.meta_key, src.meta_value
428+ FROM {$ from_prefix }postmeta src
429+ INNER JOIN {$ to_prefix }posts tgt
430+ ON tgt.ID = src.post_id
431+ AND tgt.post_type = 'nav_menu_item'
432+ WHERE src.meta_key IN ( {$ placeholders })
433+ AND NOT EXISTS (
434+ SELECT 1 FROM {$ to_prefix }postmeta tpm
435+ WHERE tpm.post_id = src.post_id
436+ AND tpm.meta_key = src.meta_key
437+ ) " ,
438+ ...$ meta_keys
439+ )
440+ );
441+ // phpcs:enable
442+ }
443+
444+ /**
445+ * Backfill attachment postmeta from template to cloned site.
446+ *
447+ * MUCD copies attachment posts but not their postmeta. Without
448+ * _wp_attached_file, wp_get_attachment_image_url() returns false and
449+ * images disappear even though the physical files exist on disk.
450+ *
451+ * @since 2.3.1
452+ *
453+ * @param int $from_site_id Source blog ID.
454+ * @param int $to_site_id Target blog ID.
455+ */
456+ protected static function backfill_attachment_postmeta ($ from_site_id , $ to_site_id ) {
457+
458+ global $ wpdb ;
459+
460+ $ from_prefix = $ wpdb ->get_blog_prefix ($ from_site_id );
461+ $ to_prefix = $ wpdb ->get_blog_prefix ($ to_site_id );
462+
463+ if ($ from_prefix === $ to_prefix ) {
464+ return ;
465+ }
466+
467+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
468+ $ wpdb ->query (
469+ "INSERT INTO {$ to_prefix }postmeta (post_id, meta_key, meta_value)
470+ SELECT src.post_id, src.meta_key, src.meta_value
471+ FROM {$ from_prefix }postmeta src
472+ INNER JOIN {$ to_prefix }posts tgt
473+ ON tgt.ID = src.post_id
474+ AND tgt.post_type = 'attachment'
475+ WHERE NOT EXISTS (
476+ SELECT 1 FROM {$ to_prefix }postmeta tpm
477+ WHERE tpm.post_id = src.post_id
478+ AND tpm.meta_key = src.meta_key
479+ ) "
480+ );
481+ // phpcs:enable
482+ }
483+
484+ /**
485+ * Backfill Elementor postmeta for all post types.
486+ *
487+ * Catch-all for any _elementor_* meta that MUCD missed. Covers
488+ * elementor_library (headers, footers, popups), e-landing-page,
489+ * elementor_snippet, and any custom post type with Elementor data.
490+ *
491+ * @since 2.3.1
492+ *
493+ * @param int $from_site_id Source blog ID.
494+ * @param int $to_site_id Target blog ID.
495+ */
496+ protected static function backfill_elementor_postmeta ($ from_site_id , $ to_site_id ) {
497+
498+ global $ wpdb ;
499+
500+ $ from_prefix = $ wpdb ->get_blog_prefix ($ from_site_id );
501+ $ to_prefix = $ wpdb ->get_blog_prefix ($ to_site_id );
502+
503+ if ($ from_prefix === $ to_prefix ) {
504+ return ;
505+ }
506+
507+ $ like_pattern = $ wpdb ->esc_like ('_elementor ' ) . '% ' ;
508+
509+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
510+ $ wpdb ->query (
511+ $ wpdb ->prepare (
512+ "INSERT INTO {$ to_prefix }postmeta (post_id, meta_key, meta_value)
513+ SELECT src.post_id, src.meta_key, src.meta_value
514+ FROM {$ from_prefix }postmeta src
515+ INNER JOIN {$ to_prefix }posts tgt
516+ ON tgt.ID = src.post_id
517+ WHERE src.meta_key LIKE %s
518+ AND NOT EXISTS (
519+ SELECT 1 FROM {$ to_prefix }postmeta tpm
520+ WHERE tpm.post_id = src.post_id
521+ AND tpm.meta_key = src.meta_key
522+ ) " ,
523+ $ like_pattern
524+ )
525+ );
526+ // phpcs:enable
527+ }
528+
529+ /**
530+ * Force-overwrite the Elementor Kit settings on the cloned site.
531+ *
532+ * The Kit post (holding colors, typography, logo) gets created with stub
533+ * Elementor defaults BEFORE MUCD runs its INSERT ... SELECT. Because MUCD
534+ * uses INSERT NOT EXISTS, the stub row is never overwritten, leaving the
535+ * clone with default Elementor colors instead of the template palette.
536+ *
537+ * This method reads the real settings from the template and uses
538+ * update_post_meta() to guarantee the overwrite.
539+ *
540+ * @since 2.3.1
541+ *
542+ * @param int $from_site_id Source blog ID.
543+ * @param int $to_site_id Target blog ID.
544+ */
545+ protected static function backfill_kit_settings ($ from_site_id , $ to_site_id ) {
546+
547+ // Read kit settings from the template site.
548+ switch_to_blog ($ from_site_id );
549+
550+ $ kit_id_from = (int ) get_option ('elementor_active_kit ' , 0 );
551+ $ kit_settings = $ kit_id_from ? get_post_meta ($ kit_id_from , '_elementor_page_settings ' , true ) : '' ;
552+ $ kit_data = $ kit_id_from ? get_post_meta ($ kit_id_from , '_elementor_data ' , true ) : '' ;
553+
554+ restore_current_blog ();
555+
556+ if (empty ($ kit_settings )) {
557+ return ;
558+ }
559+
560+ // Force-overwrite kit settings on the target site.
561+ // Uses update_post_meta() instead of INSERT NOT EXISTS because
562+ // the target kit may already have stub metadata from Elementor's
563+ // activation routine. INSERT NOT EXISTS would silently skip the
564+ // row, leaving the clone with default Elementor colors.
565+ switch_to_blog ($ to_site_id );
566+
567+ $ kit_id_to = (int ) get_option ('elementor_active_kit ' , 0 );
568+
569+ if ( ! $ kit_id_to && $ kit_id_from ) {
570+ $ kit_id_to = $ kit_id_from ;
571+ update_option ('elementor_active_kit ' , $ kit_id_to );
572+ }
573+
574+ if ($ kit_id_to ) {
575+ update_post_meta ($ kit_id_to , '_elementor_page_settings ' , $ kit_settings );
576+
577+ if ( ! empty ($ kit_data ) && '[] ' !== $ kit_data ) {
578+ update_post_meta ($ kit_id_to , '_elementor_data ' , $ kit_data );
579+ }
580+
581+ // Clear compiled CSS so Elementor_Compat::regenerate_css() will
582+ // rebuild with the correct Kit settings on wu_duplicate_site.
583+ delete_post_meta ($ kit_id_to , '_elementor_css ' );
584+ }
585+
586+ restore_current_blog ();
587+ }
588+
589+ /**
590+ * Verify Kit integrity after clone and re-apply if mismatched.
591+ *
592+ * Compares the byte length of _elementor_page_settings between the
593+ * template and the clone. If the clone has less than 80% of the
594+ * template's byte count, the Kit fix is re-applied as a safety net.
595+ *
596+ * This catches edge cases where update_post_meta() succeeded but the
597+ * stored value was truncated by a concurrent write, or where Elementor's
598+ * activation routine overwrote the Kit settings after backfill.
599+ *
600+ * @since 2.3.1
601+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
602+ *
603+ * @param int $from_site_id Source blog ID.
604+ * @param int $to_site_id Target blog ID.
605+ */
606+ protected static function verify_kit_integrity ($ from_site_id , $ to_site_id ) {
607+
608+ $ from_site_id = (int ) $ from_site_id ;
609+ $ to_site_id = (int ) $ to_site_id ;
610+
611+ if ( ! $ from_site_id || ! $ to_site_id || $ from_site_id === $ to_site_id ) {
612+ return ;
613+ }
614+
615+ switch_to_blog ($ from_site_id );
616+ $ kit_id_from = (int ) get_option ('elementor_active_kit ' , 0 );
617+ $ from_size = $ kit_id_from ? strlen (maybe_serialize (get_post_meta ($ kit_id_from , '_elementor_page_settings ' , true ))) : 0 ;
618+ restore_current_blog ();
619+
620+ switch_to_blog ($ to_site_id );
621+ $ kit_id_to = (int ) get_option ('elementor_active_kit ' , 0 );
622+ $ to_size = $ kit_id_to ? strlen (maybe_serialize (get_post_meta ($ kit_id_to , '_elementor_page_settings ' , true ))) : 0 ;
623+ restore_current_blog ();
624+
625+ if ( ! $ from_size || ! $ to_size ) {
626+ return ;
627+ }
628+
629+ // If the clone has less than 80% of the template's byte count,
630+ // the Kit settings are likely incomplete — re-apply the fix.
631+ if ($ to_size < ($ from_size * 0.8 )) {
632+ self ::backfill_kit_settings ($ from_site_id , $ to_site_id );
633+ }
634+ }
314635}
0 commit comments