Skip to content

Commit 175714f

Browse files
GH#820: backfill postmeta for nav_menu_item, attachment, and Elementor posts on site clone (#825)
* fix(duplication): backfill postmeta for nav_menu_item, attachment, and Elementor posts on site clone MUCD_Data::copy_data() uses INSERT...SELECT but misses postmeta rows for nav_menu_item, attachment, and elementor_library posts. The Elementor Kit also gets stub postmeta that INSERT NOT EXISTS silently skips. Add backfill_postmeta() after copy_data() with four targeted methods: - backfill_nav_menu_postmeta(): fills _menu_item_* keys for nav_menu_item - backfill_attachment_postmeta(): fills _wp_attached_file etc. for attachments - backfill_elementor_postmeta(): catch-all for _elementor_* meta on any post - backfill_kit_settings(): force-overwrites Kit settings with update_post_meta Also passes from_site_id in wu_duplicate_site action args so hooks can access the source template without relying on MUCD's hardcoded params. Fixes #820 * wip: add Step 3 (verify_kit_integrity) and Step 4 (wu_template_id resolution) + unit tests * test(duplication): add unit tests for postmeta backfill methods Adds 8 tests covering all 4 bugs from GH#820: - nav_menu_item postmeta backfill (Bug 2) - attachment postmeta backfill (Bug 3) - elementor_library postmeta backfill (Bug 4) - Kit settings force-overwrite (Bug 1) - same-site skip guard - idempotency (NOT EXISTS prevents duplicates) - wu_duplicate_site action payload includes from_site_id - no-op when template has no Elementor Kit For #820 * Update tests/WP_Ultimo/Helpers/Site_Duplicator_Postmeta_Test.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent ecfa580 commit 175714f

File tree

3 files changed

+1716
-1
lines changed

3 files changed

+1716
-1
lines changed

inc/helpers/class-site-duplicator.php

Lines changed: 322 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)