Skip to content

Instantly share code, notes, and snippets.

@mlbd
Created August 17, 2025 13:08
Show Gist options
  • Save mlbd/bf2572ffce9b2e5c8f52a029eab71d37 to your computer and use it in GitHub Desktop.
Save mlbd/bf2572ffce9b2e5c8f52a029eab71d37 to your computer and use it in GitHub Desktop.
Fix Missing Elementor Repeater Images After OCDI Import (Multisite-Safe Reconciler)
<?php
/**
* Run the Elementor media reconciliation AFTER One Click Demo Import finishes.
*
* Why priority 30?
* - Ensures your own after-import tasks (menus, front/blog pages, Replace URL, etc.)
* have already completed, so media IDs/URLs are normalized last.
*
* Hook names:
* - 'ocdi/after_import' → newer hook name used by OCDI.
* - 'pt-ocdi/after_import' → legacy hook name (older plugin versions).
*
* Note:
* - We accept 1 argument ($selected_import) for compatibility with OCDI,
* even though we don't use it here.
* - If you already call uxora_reconcile_all_elementor_media() inside
* uxora_after_import_setup(), remove one call to avoid double work.
*/
// Newer hook name.
add_action( 'ocdi/after_import', function( $selected_import ) {
uxora_reconcile_all_elementor_media();
}, 30, 1 );
// Legacy hook name (kept for backward compatibility).
add_action( 'pt-ocdi/after_import', function( $selected_import ) {
uxora_reconcile_all_elementor_media();
}, 30, 1 );
/**
* Elementor media reconciliation helpers.
*
* Goal:
* - Make every Elementor media control's ['id','url'] pair consistent so Elementor
* renders the intended image. Elementor prefers the attachment ID; if an 'id'
* points to a valid local attachment, it will regenerate the URL from that ID.
* - After migrations/imports, IDs and URLs can fall out of sync (especially inside
* nested repeater controls). This routine normalizes them.
*
* Works with:
* - Single-site: URLs like /wp-content/uploads/...
* - Multisite: URLs like /wp-content/uploads/sites/{blog_id}/... (expected)
*
* Usage:
* - Run once after import/migration (e.g., temporarily from admin_init).
* - Then remove the one-time trigger. The functions themselves are safe to keep.
*/
/**
* Reconcile Elementor media across all Elementor-bearing posts.
*
* @return void
*/
function uxora_reconcile_all_elementor_media() : void {
// Query all common post types that may carry Elementor data.
$q = new WP_Query( [
'post_type' => [ 'page', 'post', 'elementor_library' ],
'posts_per_page' => -1,
'post_status' => 'any',
'fields' => 'ids',
] );
foreach ( $q->posts as $post_id ) {
// error_log( "[UXORA] reconcile start post {$post_id}" );
uxora_reconcile_elementor_media_for_post( (int) $post_id );
}
wp_reset_postdata();
// Clear Elementor's CSS/assets cache so changes are reflected immediately.
if ( class_exists( '\Elementor\Plugin' ) ) {
\Elementor\Plugin::$instance->files_manager->clear_cache();
}
}
/**
* Reconcile Elementor media for a single post.
*
* What it does per post meta key:
* 1) Load Elementor meta (JSON string or array).
* 2) Walk the entire structure (widgets, controls, repeaters).
* 3) For each media-like array:
* - If it has a valid 'id', normalize 'url' to wp_get_attachment_url( id ).
* - Else if it only has 'url' that maps to a local attachment, set the 'id' and normalize 'url'.
* - Else leave external/unattached URLs as-is.
* 4) Save back using the original storage format (JSON for _elementor_data; array or JSON for settings).
*
* @param int $post_id The post to process.
* @return void
*/
function uxora_reconcile_elementor_media_for_post( int $post_id ) : void {
// Elementor stores content under _elementor_data (JSON) and
// per-page settings under _elementor_page_settings (array or JSON).
$meta_keys = [ '_elementor_data', '_elementor_page_settings' ];
foreach ( $meta_keys as $meta_key ) {
$raw = get_post_meta( $post_id, $meta_key, true );
if ( ! $raw ) {
continue; // Nothing to process.
}
// Track whether the original value was a JSON string so we can save in the same format.
$raw_was_json = is_string( $raw );
$data = $raw_was_json ? json_decode( $raw, true ) : $raw;
if ( ! is_array( $data ) ) {
continue; // Malformed or non-array data; skip safely.
}
$changed = false;
/**
* Recursive walker that traverses the Elementor structure and reconciles media controls.
*
* @param mixed $node Current subtree.
* @return mixed Reconciled subtree.
*/
$walk = function ( $node ) use ( &$walk, &$changed, $post_id ) {
if ( is_array( $node ) ) {
// Typical Elementor media control: ['id' => 123, 'url' => 'https://...']
$has_url = isset( $node['url'] ) && is_string( $node['url'] ) && $node['url'] !== '';
$has_id = isset( $node['id'] ) && (int) $node['id'] > 0;
if ( $has_id || $has_url ) {
$id = $has_id ? (int) $node['id'] : 0;
$url = $has_url ? (string) $node['url'] : '';
// Normalize either side using WordPress core resolvers.
$url_from_id = $id ? wp_get_attachment_url( $id ) : '';
$id_from_url = $url ? attachment_url_to_postid( $url ) : 0;
if ( $id && $url_from_id ) {
// We trust the ID and make the stored 'url' match WordPress.
if ( ! $has_url || $url !== $url_from_id ) {
$node['url'] = $url_from_id;
$changed = true;
}
} elseif ( ! $id && $id_from_url ) {
// No ID stored, but URL points to a local attachment: fix both.
$node['id'] = $id_from_url;
$node['url'] = wp_get_attachment_url( $id_from_url ) ?: $url;
$changed = true;
} else {
// External URL or unattached file: leave as-is (Elementor will use the URL).
// No change needed.
}
}
// Recurse into all children.
foreach ( $node as $k => $v ) {
$node[ $k ] = $walk( $v );
}
}
return $node;
};
$new = $walk( $data );
// Save only if something changed.
if ( $changed ) {
if ( '_elementor_data' === $meta_key ) {
// _elementor_data must be JSON-encoded.
update_post_meta( $post_id, $meta_key, wp_slash( wp_json_encode( $new ) ) );
} else {
// For settings, preserve the original storage format.
if ( $raw_was_json ) {
update_post_meta( $post_id, $meta_key, wp_slash( wp_json_encode( $new ) ) );
} else {
update_post_meta( $post_id, $meta_key, $new );
}
}
// error_log( "[UXORA] reconciled {$meta_key} for post {$post_id}" );
} else {
// error_log( "[UXORA] no changes in {$meta_key} for post {$post_id}" );
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment