fix(elementor): port v1.9.0 data layer — save_page_data wp_slash fallback, CSS invalidation, by-ref insert/remove/update, normalize_container_settings, generate_id uses random_bytes

This commit is contained in:
2026-06-02 20:44:47 +00:00
parent 96583831bb
commit 10450b5233
+175 -191
View File
@@ -3,20 +3,16 @@ namespace AskTamMCP\Elementor\Data;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Data access layer for Elementor page data. * Elementor data access layer.
* *
* Read/write strategy: * Methods ported from elementor-mcp v1.9.0 (class-elementor-data.php).
* CLI / DOING_CRON → raw $wpdb queries, bypassing WP object cache entirely. * CLI/cron context uses raw $wpdb queries to bypass WP object cache —
* HTTP context → document->save() first; falls back to direct meta write * the core fix for stale-read bugs in long-running build scripts.
* with wp_slash() + CSS cache invalidation on failure.
*
* Methods mirror the public API of Elementor_MCP_Data (v1.9.0) so both
* HTTP and CLI callers use identical call signatures.
*/ */
class ElementorData { class ElementorData {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Document access // get_document
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function get_document( int $post_id ) { public function get_document( int $post_id ) {
@@ -25,25 +21,24 @@ class ElementorData {
} }
$document = \Elementor\Plugin::$instance->documents->get( $post_id ); $document = \Elementor\Plugin::$instance->documents->get( $post_id );
if ( ! $document ) { if ( ! $document ) {
return new \WP_Error('document_not_found', "Elementor document not found for post ID {$post_id}."); return new \WP_Error(
'document_not_found',
sprintf( 'Elementor document not found for post ID %d.', $post_id )
);
} }
return $document; return $document;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Read page data // get_page_data
// Tries document API first, falls back to raw DB read for CLI/cron.
// Matches upstream v1.9.0 get_page_data() with our CLI-bypass layer.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Gets the element tree for an Elementor page.
*
* CLI/cron: reads directly from $wpdb, bypassing WP object cache entirely.
* HTTP: tries document->get_elements_data(), falls back to get_post_meta(),
* then raw $wpdb as last resort (handles all proxy/REST contexts).
*/
public function get_page_data( int $post_id ) { public function get_page_data( int $post_id ) {
// CLI/cron: WP object cache stays stale after meta writes done outside // CLI/cron: bypass WP object cache entirely — prevents stale reads in
// document->save(). Always read raw from DB. // long-running scripts where update_post_meta calls leave the cache
// inconsistent (the root cause of elements overwriting each other).
if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
global $wpdb; global $wpdb;
$raw = $wpdb->get_var( $wpdb->prepare( $raw = $wpdb->get_var( $wpdb->prepare(
@@ -52,60 +47,63 @@ class ElementorData {
) ); ) );
if ( ! empty( $raw ) && is_string( $raw ) ) { if ( ! empty( $raw ) && is_string( $raw ) ) {
$decoded = json_decode( $raw, true ); $decoded = json_decode( $raw, true );
if (is_array($decoded)) return $decoded; if ( is_array( $decoded ) ) {
return $decoded;
} }
return []; }
return array();
} }
// HTTP: try Elementor document API first (canonical path). // Web context: try document API first (upstream v1.9.0 pattern).
$document = $this->get_document( $post_id ); $document = $this->get_document( $post_id );
if (is_wp_error($document)) return $document; if ( is_wp_error( $document ) ) {
return $document;
}
$data = $document->get_elements_data(); $data = $document->get_elements_data();
if (is_array($data) && !empty($data)) return $data; if ( is_array( $data ) && ! empty( $data ) ) {
return $data;
}
// Fallback: raw get_post_meta (handles REST/proxy contexts). // Fallback: read from raw post meta (handles proxy / REST contexts).
$raw = get_post_meta( $post_id, '_elementor_data', true ); $raw = get_post_meta( $post_id, '_elementor_data', true );
if ( ! empty( $raw ) && is_string( $raw ) ) { if ( ! empty( $raw ) && is_string( $raw ) ) {
$decoded = json_decode( $raw, true ); $decoded = json_decode( $raw, true );
if (is_array($decoded)) return $decoded; if ( is_array( $decoded ) ) {
return $decoded;
}
} }
// Last resort: bypass object cache entirely. return array();
global $wpdb;
$raw = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id
));
if (!empty($raw) && is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) return $decoded;
}
return [];
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Save page data // get_page_settings
// -------------------------------------------------------------------------
public function get_page_settings( int $post_id ) {
$document = $this->get_document( $post_id );
if ( is_wp_error( $document ) ) {
return $document;
}
return $document->get_settings();
}
// -------------------------------------------------------------------------
// save_page_data
// Upstream v1.9.0: try document->save() first; fall back to direct meta
// write with wp_slash() + CSS cache invalidation.
// CLI/cron uses raw $wpdb to avoid object-cache inconsistencies and the
// wp_update_post email fatal (known issue #3 in 05-KNOWN-ISSUES.md).
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Saves element tree for an Elementor page.
*
* CLI/cron: raw $wpdb INSERT/UPDATE + object cache flush.
* HTTP: document->save() first; on failure falls back to direct meta write
* with wp_slash() + CSS cache invalidation (mirrors v1.9.0 fallback).
*/
public function save_page_data( int $post_id, array $data ) { public function save_page_data( int $post_id, array $data ) {
// CLI/cron: skip document->save() entirely. // CLI/cron fast path: raw DB write, bypasses object cache + document->save().
// - avoids wp_update_post mail crash (no sendmail in container)
// - avoids WP object cache stale-write problem in long-running scripts
if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { if ( PHP_SAPI === 'cli' || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
global $wpdb; global $wpdb;
$json = wp_json_encode( $data ); $json = wp_json_encode( $data );
$table = $wpdb->postmeta; $table = $wpdb->postmeta;
// _elementor_data
$exists = $wpdb->get_var( $wpdb->prepare( $exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1", "SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id $post_id
@@ -122,15 +120,15 @@ class ElementorData {
) ); ) );
} }
// _elementor_edit_mode // Ensure Elementor meta flags are set.
$modeExists = $wpdb->get_var($wpdb->prepare( $mode_exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_edit_mode' LIMIT 1", "SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_edit_mode' LIMIT 1",
$post_id $post_id
) ); ) );
if ($modeExists) { if ( $mode_exists ) {
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET meta_value='builder' WHERE meta_id=%d", "UPDATE {$table} SET meta_value='builder' WHERE meta_id=%d",
(int)$modeExists (int) $mode_exists
) ); ) );
} else { } else {
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
@@ -139,16 +137,15 @@ class ElementorData {
) ); ) );
} }
// _elementor_version
if ( defined( 'ELEMENTOR_VERSION' ) ) { if ( defined( 'ELEMENTOR_VERSION' ) ) {
$verExists = $wpdb->get_var($wpdb->prepare( $ver_exists = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_version' LIMIT 1", "SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_version' LIMIT 1",
$post_id $post_id
) ); ) );
if ($verExists) { if ( $ver_exists ) {
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET meta_value=%s WHERE meta_id=%d", "UPDATE {$table} SET meta_value=%s WHERE meta_id=%d",
ELEMENTOR_VERSION, (int)$verExists ELEMENTOR_VERSION, (int) $ver_exists
) ); ) );
} else { } else {
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
@@ -158,39 +155,40 @@ class ElementorData {
} }
} }
// Invalidate _elementor_css meta so Elementor regenerates it. // Invalidate Elementor CSS cache so it regenerates on next page view.
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE post_id=%d AND meta_key='_elementor_css'", "DELETE FROM {$table} WHERE post_id=%d AND meta_key='_elementor_css'",
$post_id $post_id
) ); ) );
// Remove physical CSS file so front-end regenerates on next load.
$upload_dir = wp_get_upload_dir(); $upload_dir = wp_get_upload_dir();
$css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css'; $css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
if ( file_exists( $css_path ) ) { if ( file_exists( $css_path ) ) {
@unlink($css_path); wp_delete_file( $css_path );
} }
// Flush WP object cache for this post so next get_post_meta reads fresh. // Invalidate WP object cache so next get_post_meta in same process reads fresh.
wp_cache_delete( $post_id, 'post_meta' ); wp_cache_delete( $post_id, 'post_meta' );
return true; return true;
} }
// HTTP: attempt native Elementor save (handles CSS regen, cache busting). // Web context: try Elementor native save (handles CSS regen, cache busting).
$document = $this->get_document( $post_id ); $document = $this->get_document( $post_id );
if (is_wp_error($document)) return $document; if ( is_wp_error( $document ) ) {
return $document;
}
$result = $document->save(['elements' => $data]); $result = $document->save( array( 'elements' => $data ) );
if ( false === $result ) { if ( false === $result ) {
// Fallback: direct meta write for non-browser contexts (REST proxy etc.). // Fallback: direct meta write for non-browser contexts (REST proxy).
// wp_slash() is required — WordPress double-unslashes on save.
$json = wp_json_encode( $data ); $json = wp_json_encode( $data );
if ( false === $json ) { if ( false === $json ) {
return new \WP_Error( 'json_encode_failed', 'Failed to encode element data as JSON.' ); return new \WP_Error( 'json_encode_failed', 'Failed to encode element data as JSON.' );
} }
// wp_slash() is required — update_post_meta strips one level of slashes.
update_post_meta( $post_id, '_elementor_data', wp_slash( $json ) ); update_post_meta( $post_id, '_elementor_data', wp_slash( $json ) );
update_post_meta( $post_id, '_elementor_edit_mode', 'builder' ); update_post_meta( $post_id, '_elementor_edit_mode', 'builder' );
@@ -198,7 +196,7 @@ class ElementorData {
update_post_meta( $post_id, '_elementor_version', ELEMENTOR_VERSION ); update_post_meta( $post_id, '_elementor_version', ELEMENTOR_VERSION );
} }
// Invalidate Elementor CSS cache so it regenerates on next page view. // Invalidate CSS cache.
delete_post_meta( $post_id, '_elementor_css' ); delete_post_meta( $post_id, '_elementor_css' );
$upload_dir = wp_get_upload_dir(); $upload_dir = wp_get_upload_dir();
@@ -212,32 +210,24 @@ class ElementorData {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Page settings // save_page_settings
// Upstream v1.9.0: try document->save(); fall back to direct meta merge.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function get_page_settings(int $post_id) {
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
return $document->get_settings();
}
/**
* Saves page-level settings.
*
* Tries native document->save() first; falls back to direct meta merge
* for non-browser contexts (mirrors v1.9.0 save_page_settings fallback).
*/
public function save_page_settings( int $post_id, array $settings ) { public function save_page_settings( int $post_id, array $settings ) {
$document = $this->get_document( $post_id ); $document = $this->get_document( $post_id );
if (is_wp_error($document)) return $document; if ( is_wp_error( $document ) ) {
return $document;
}
$result = $document->save(['settings' => $settings]); $result = $document->save( array( 'settings' => $settings ) );
if ( false === $result ) { if ( false === $result ) {
$existing = get_post_meta( $post_id, '_elementor_page_settings', true ); $existing = get_post_meta( $post_id, '_elementor_page_settings', true );
if (!is_array($existing)) $existing = []; if ( ! is_array( $existing ) ) {
$merged = array_merge($existing, $settings); $existing = array();
update_post_meta($post_id, '_elementor_page_settings', $merged); }
update_post_meta( $post_id, '_elementor_page_settings', array_merge( $existing, $settings ) );
delete_post_meta( $post_id, '_elementor_css' ); delete_post_meta( $post_id, '_elementor_css' );
} }
@@ -245,72 +235,50 @@ class ElementorData {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Widget registry // find_element (upstream: find_element_by_id)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function get_registered_widgets(): array {
if (!class_exists('\Elementor\Plugin')) return [];
$widgets = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
$result = [];
foreach ($widgets as $widget) {
$result[] = ['name' => $widget->get_name(), 'title' => $widget->get_title()];
}
return $result;
}
// -------------------------------------------------------------------------
// ID generation
// -------------------------------------------------------------------------
/**
* Generates a 7-character random hex string matching Elementor's format.
* Uses random_bytes() for proper entropy (mirrors Elementor_MCP_Id_Generator).
*/
public function generate_id(): string {
return substr(bin2hex(random_bytes(4)), 0, 7);
}
// -------------------------------------------------------------------------
// Element tree helpers
// -------------------------------------------------------------------------
/**
* Recursively finds an element by ID within a tree.
* Mirrors find_element_by_id() from Elementor_MCP_Data v1.9.0.
*/
public function find_element( array $elements, string $element_id ): ?array { public function find_element( array $elements, string $element_id ): ?array {
foreach ( $elements as $element ) { foreach ( $elements as $element ) {
if (($element['id'] ?? '') === $element_id) return $element; if ( ( $element['id'] ?? '' ) === $element_id ) {
if (!empty($element['elements'])) { return $element;
}
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$found = $this->find_element( $element['elements'], $element_id ); $found = $this->find_element( $element['elements'], $element_id );
if ($found !== null) return $found; if ( null !== $found ) {
return $found;
}
} }
} }
return null; return null;
} }
/** // -------------------------------------------------------------------------
* Inserts an element into the tree under parent_id at position. // insert_element (upstream: insert_element — by reference)
* Empty parent_id = top-level insertion. // Supports top-level insertion (empty parent_id) and nested.
* Mirrors insert_element() from Elementor_MCP_Data v1.9.0 (by-reference). // -------------------------------------------------------------------------
*/
public function insert_element( array &$data, string $parent_id, array $element, int $position = -1 ): bool { public function insert_element( array &$data, string $parent_id, array $element, int $position = -1 ): bool {
// Top-level insertion.
if ( empty( $parent_id ) ) { if ( empty( $parent_id ) ) {
if ( $position < 0 || $position >= count( $data ) ) { if ( $position < 0 || $position >= count( $data ) ) {
$data[] = $element; $data[] = $element;
} else { } else {
array_splice($data, $position, 0, [$element]); array_splice( $data, $position, 0, array( $element ) );
} }
return true; return true;
} }
// Find parent and insert.
foreach ( $data as &$item ) { foreach ( $data as &$item ) {
if ( isset( $item['id'] ) && $item['id'] === $parent_id ) { if ( isset( $item['id'] ) && $item['id'] === $parent_id ) {
if (!isset($item['elements'])) $item['elements'] = []; if ( ! isset( $item['elements'] ) ) {
$item['elements'] = array();
}
if ( $position < 0 || $position >= count( $item['elements'] ) ) { if ( $position < 0 || $position >= count( $item['elements'] ) ) {
$item['elements'][] = $element; $item['elements'][] = $element;
} else { } else {
array_splice($item['elements'], $position, 0, [$element]); array_splice( $item['elements'], $position, 0, array( $element ) );
} }
return true; return true;
} }
@@ -324,10 +292,10 @@ class ElementorData {
return false; return false;
} }
/** // -------------------------------------------------------------------------
* Removes an element from the tree by ID. // remove_element (upstream: remove_element — by reference)
* Mirrors remove_element() from Elementor_MCP_Data v1.9.0 (by-reference). // -------------------------------------------------------------------------
*/
public function remove_element( array &$data, string $element_id ): bool { public function remove_element( array &$data, string $element_id ): bool {
foreach ( $data as $index => &$item ) { foreach ( $data as $index => &$item ) {
if ( isset( $item['id'] ) && $item['id'] === $element_id ) { if ( isset( $item['id'] ) && $item['id'] === $element_id ) {
@@ -343,22 +311,28 @@ class ElementorData {
return false; return false;
} }
/** // -------------------------------------------------------------------------
* Updates settings for a specific element in the tree (by-reference). // update_element_settings (upstream: update_element_settings — by reference)
* // Normalises container flex-key aliases before merging (issue #32 fix from
* Applies container key alias normalisation (justify_content → flex_justify_content // upstream v1.5.1): justify_content→flex_justify_content, etc.
* etc.) before merging — mirrors update_element_settings() v1.9.0 which calls // -------------------------------------------------------------------------
* normalize_container_settings() when elType=container.
*/
public function update_element_settings( array &$data, string $element_id, array $settings, bool $merge = true ): bool { public function update_element_settings( array &$data, string $element_id, array $settings, bool $merge = true ): bool {
foreach ( $data as &$item ) { foreach ( $data as &$item ) {
if (($item['id'] ?? '') === $element_id) { if ( isset( $item['id'] ) && $item['id'] === $element_id ) {
// Normalise flex shorthand keys for containers. if ( ! isset( $item['settings'] ) ) {
if (($item['elType'] ?? '') === 'container') { $item['settings'] = array();
$settings = self::normalize_container_settings($settings);
} }
$current = $item['settings'] ?? [];
$item['settings'] = $merge ? array_merge($current, $settings) : $settings; // Normalise container flex shorthand keys (upstream v1.5.1 fix).
if ( 'container' === ( $item['elType'] ?? '' ) ) {
$settings = $this->normalize_container_settings( $settings );
}
$item['settings'] = $merge
? array_merge( $item['settings'], $settings )
: $settings;
return true; return true;
} }
if ( ! empty( $item['elements'] ) && is_array( $item['elements'] ) ) { if ( ! empty( $item['elements'] ) && is_array( $item['elements'] ) ) {
@@ -370,10 +344,35 @@ class ElementorData {
return false; return false;
} }
/** // -------------------------------------------------------------------------
* Recursively reassigns fresh IDs to all elements in a tree. // normalize_container_settings
* Mirrors reassign_ids() from Elementor_MCP_Data v1.9.0. // Upstream v1.9.0 Element_Factory::normalize_container_settings().
*/ // Rewrites unprefixed flex shorthand keys to the keys Elementor CSS
// generator actually reads (issue #32).
// -------------------------------------------------------------------------
public function normalize_container_settings( array $settings ): array {
$aliases = array(
'justify_content' => 'flex_justify_content',
'align_items' => 'flex_align_items',
'align_content' => 'flex_align_content',
);
foreach ( $aliases as $shorthand => $flex_key ) {
if ( ! array_key_exists( $shorthand, $settings ) ) {
continue;
}
if ( ! array_key_exists( $flex_key, $settings ) ) {
$settings[ $flex_key ] = $settings[ $shorthand ];
}
unset( $settings[ $shorthand ] );
}
return $settings;
}
// -------------------------------------------------------------------------
// reassign_ids (upstream: reassign_ids + reassign_element_ids)
// -------------------------------------------------------------------------
public function reassign_ids( array $elements ): array { public function reassign_ids( array $elements ): array {
foreach ( $elements as &$element ) { foreach ( $elements as &$element ) {
$element['id'] = $this->generate_id(); $element['id'] = $this->generate_id();
@@ -384,25 +383,14 @@ class ElementorData {
return $elements; return $elements;
} }
/** // -------------------------------------------------------------------------
* Reassigns a fresh ID to a single element and all its children. // count_elements (upstream: count_elements)
* Mirrors reassign_element_ids() from Elementor_MCP_Data v1.9.0. // -------------------------------------------------------------------------
*/
public function reassign_element_ids(array $element): array {
$element['id'] = $this->generate_id();
if (!empty($element['elements']) && is_array($element['elements'])) {
$element['elements'] = $this->reassign_ids($element['elements']);
}
return $element;
}
/**
* Recursively counts all elements in a tree.
*/
public function count_elements( array $elements ): int { public function count_elements( array $elements ): int {
$count = count( $elements ); $count = count( $elements );
foreach ( $elements as $element ) { foreach ( $elements as $element ) {
if (!empty($element['elements'])) { if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$count += $this->count_elements( $element['elements'] ); $count += $this->count_elements( $element['elements'] );
} }
} }
@@ -410,34 +398,30 @@ class ElementorData {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Container key normalisation // get_registered_widgets
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** public function get_registered_widgets(): array {
* Rewrites unprefixed flex shorthand keys to the prefixed keys that if ( ! class_exists( '\Elementor\Plugin' ) ) {
* Elementor's container CSS generator actually reads. return array();
* }
* Without this, justify_content / align_items / align_content are persisted $widgets = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
* under names Elementor ignores and the container renders with default $result = array();
* alignment on the front-end. foreach ( $widgets as $widget ) {
* $result[] = array(
* Mirrors CONTAINER_KEY_ALIASES + normalize_container_settings() from 'name' => $widget->get_name(),
* Elementor_MCP_Element_Factory v1.9.0. 'title' => $widget->get_title(),
*/ );
private const CONTAINER_KEY_ALIASES = [ }
'justify_content' => 'flex_justify_content', return $result;
'align_items' => 'flex_align_items', }
'align_content' => 'flex_align_content',
];
public static function normalize_container_settings(array $settings): array { // -------------------------------------------------------------------------
foreach (self::CONTAINER_KEY_ALIASES as $shorthand => $flex_key) { // generate_id (upstream: Elementor_MCP_Id_Generator::generate)
if (!array_key_exists($shorthand, $settings)) continue; // 7-char hex matching Elementor's internal format.
if (!array_key_exists($flex_key, $settings)) { // -------------------------------------------------------------------------
$settings[$flex_key] = $settings[$shorthand];
} public function generate_id(): string {
unset($settings[$shorthand]); return substr( bin2hex( random_bytes( 4 ) ), 0, 7 );
}
return $settings;
} }
} }