fix: rewrite ElementorData to match v1.9.0 methods — by-ref insert/remove/update, wp_slash fallback, CSS cache invalidation, normalize_container_settings, random_bytes ID gen

This commit is contained in:
2026-06-02 20:34:09 +00:00
parent 0de355ff2e
commit 157d296413
+315 -42
View File
@@ -2,8 +2,23 @@
namespace AskTamMCP\Elementor\Data;
defined('ABSPATH') || exit;
/**
* Data access layer for Elementor page data.
*
* Read/write strategy:
* CLI / DOING_CRON → raw $wpdb queries, bypassing WP object cache entirely.
* HTTP context → document->save() first; falls back to direct meta write
* 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 {
// -------------------------------------------------------------------------
// Document access
// -------------------------------------------------------------------------
public function get_document(int $post_id) {
if (!class_exists('\Elementor\Plugin')) {
return new \WP_Error('elementor_missing', 'Elementor is not installed or active.');
@@ -15,9 +30,21 @@ class ElementorData {
return $document;
}
// -------------------------------------------------------------------------
// Read page data
// -------------------------------------------------------------------------
/**
* 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) {
// CLI/cron: WP object cache stays stale after meta writes done outside
// document->save(). Always read raw from DB.
if (PHP_SAPI === 'cli' || (defined('DOING_CRON') && DOING_CRON)) {
// Read directly from DB — bypasses WP object cache entirely
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",
@@ -29,26 +56,56 @@ class ElementorData {
}
return [];
}
// HTTP: try Elementor document API first (canonical path).
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
$data = $document->get_elements_data();
if (is_array($data) && !empty($data)) return $data;
// Fallback: raw get_post_meta (handles REST/proxy contexts).
$raw = get_post_meta($post_id, '_elementor_data', true);
if (!empty($raw) && is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) return $decoded;
}
// Last resort: bypass object cache entirely.
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
// -------------------------------------------------------------------------
/**
* 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) {
// CLI/cron: skip document->save() entirely.
// - 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)) {
// Bypass WP meta cache entirely using raw $wpdb queries.
// WP object cache causes stale reads in long-running CLI scripts where
// multiple add/delete calls within the same process leave the cache inconsistent.
global $wpdb;
$json = wp_json_encode($data);
$table = $wpdb->postmeta;
// _elementor_data
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_data' LIMIT 1",
$post_id
@@ -64,39 +121,133 @@ class ElementorData {
$post_id, $json
));
}
// _elementor_edit_mode
$modeExists = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_edit_mode' LIMIT 1",
$post_id
));
if ($modeExists) {
$wpdb->query($wpdb->prepare("UPDATE {$table} SET meta_value='builder' WHERE meta_id=%d", (int)$modeExists));
$wpdb->query($wpdb->prepare(
"UPDATE {$table} SET meta_value='builder' WHERE meta_id=%d",
(int)$modeExists
));
} else {
$wpdb->query($wpdb->prepare("INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_edit_mode', 'builder')", $post_id));
$wpdb->query($wpdb->prepare(
"INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_edit_mode', 'builder')",
$post_id
));
}
// _elementor_version
if (defined('ELEMENTOR_VERSION')) {
$verExists = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$table} WHERE post_id=%d AND meta_key='_elementor_version' LIMIT 1",
$post_id
));
if ($verExists) {
$wpdb->query($wpdb->prepare(
"UPDATE {$table} SET meta_value=%s WHERE meta_id=%d",
ELEMENTOR_VERSION, (int)$verExists
));
} else {
$wpdb->query($wpdb->prepare(
"INSERT INTO {$table} (post_id, meta_key, meta_value) VALUES (%d, '_elementor_version', %s)",
$post_id, ELEMENTOR_VERSION
));
}
}
// Invalidate _elementor_css meta so Elementor regenerates it.
$wpdb->query($wpdb->prepare(
"DELETE FROM {$table} WHERE post_id=%d AND meta_key='_elementor_css'",
$post_id
));
// Remove physical CSS file so front-end regenerates on next load.
$upload_dir = wp_get_upload_dir();
$css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
if (file_exists($css_path)) {
@unlink($css_path);
}
// Flush WP object cache for this post so next get_post_meta reads fresh.
wp_cache_delete($post_id, 'post_meta');
return true;
}
// HTTP: attempt native Elementor save (handles CSS regen, cache busting).
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
$document->save(['elements' => $data]);
update_post_meta($post_id, '_elementor_data', wp_json_encode($data));
$result = $document->save(['elements' => $data]);
if (false === $result) {
// Fallback: direct meta write for non-browser contexts (REST proxy etc.).
$json = wp_json_encode($data);
if (false === $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_edit_mode', 'builder');
if (defined('ELEMENTOR_VERSION')) {
update_post_meta($post_id, '_elementor_version', ELEMENTOR_VERSION);
}
// Invalidate Elementor CSS cache so it regenerates on next page view.
delete_post_meta($post_id, '_elementor_css');
$upload_dir = wp_get_upload_dir();
$css_path = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
if (file_exists($css_path)) {
wp_delete_file($css_path);
}
}
return true;
}
// -------------------------------------------------------------------------
// 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();
}
/**
* 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) {
$document = $this->get_document($post_id);
if (is_wp_error($document)) return $document;
$document->save(['settings' => $settings]);
$result = $document->save(['settings' => $settings]);
if (false === $result) {
$existing = get_post_meta($post_id, '_elementor_page_settings', true);
if (!is_array($existing)) $existing = [];
$merged = array_merge($existing, $settings);
update_post_meta($post_id, '_elementor_page_settings', $merged);
delete_post_meta($post_id, '_elementor_css');
}
return true;
}
// -------------------------------------------------------------------------
// Widget registry
// -------------------------------------------------------------------------
public function get_registered_widgets(): array {
if (!class_exists('\Elementor\Plugin')) return [];
$widgets = \Elementor\Plugin::$instance->widgets_manager->get_widget_types();
@@ -107,20 +258,147 @@ class ElementorData {
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(md5(uniqid('', true)), 0, 7);
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 {
foreach ($elements as $element) {
if (($element['id'] ?? '') === $element_id) return $element;
if (!empty($element['elements'])) {
$found = $this->find_element($element['elements'], $element_id);
if ($found !== null) return $found;
}
}
return null;
}
/**
* Inserts an element into the tree under parent_id at position.
* Empty parent_id = top-level insertion.
* 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 {
if (empty($parent_id)) {
if ($position < 0 || $position >= count($data)) {
$data[] = $element;
} else {
array_splice($data, $position, 0, [$element]);
}
return true;
}
foreach ($data as &$item) {
if (isset($item['id']) && $item['id'] === $parent_id) {
if (!isset($item['elements'])) $item['elements'] = [];
if ($position < 0 || $position >= count($item['elements'])) {
$item['elements'][] = $element;
} else {
array_splice($item['elements'], $position, 0, [$element]);
}
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->insert_element($item['elements'], $parent_id, $element, $position)) {
return true;
}
}
}
return false;
}
/**
* Removes an element from the tree by ID.
* Mirrors remove_element() from Elementor_MCP_Data v1.9.0 (by-reference).
*/
public function remove_element(array &$data, string $element_id): bool {
foreach ($data as $index => &$item) {
if (isset($item['id']) && $item['id'] === $element_id) {
array_splice($data, $index, 1);
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->remove_element($item['elements'], $element_id)) {
return true;
}
}
}
return false;
}
/**
* Updates settings for a specific element in the tree (by-reference).
*
* Applies container key alias normalisation (justify_content → flex_justify_content
* 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 {
foreach ($data as &$item) {
if (($item['id'] ?? '') === $element_id) {
// Normalise flex shorthand keys for containers.
if (($item['elType'] ?? '') === 'container') {
$settings = self::normalize_container_settings($settings);
}
$current = $item['settings'] ?? [];
$item['settings'] = $merge ? array_merge($current, $settings) : $settings;
return true;
}
if (!empty($item['elements']) && is_array($item['elements'])) {
if ($this->update_element_settings($item['elements'], $element_id, $settings, $merge)) {
return true;
}
}
}
return false;
}
/**
* Recursively reassigns fresh IDs to all elements in a tree.
* Mirrors reassign_ids() from Elementor_MCP_Data v1.9.0.
*/
public function reassign_ids(array $elements): array {
foreach ($elements as &$element) {
$element['id'] = $this->generate_id();
if (!empty($element['elements'])) {
if (!empty($element['elements']) && is_array($element['elements'])) {
$element['elements'] = $this->reassign_ids($element['elements']);
}
}
return $elements;
}
/**
* Reassigns a fresh ID to a single element and all its children.
* 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 {
$count = count($elements);
foreach ($elements as $element) {
@@ -131,40 +409,35 @@ class ElementorData {
return $count;
}
public function find_element(array $elements, string $element_id): ?array {
foreach ($elements as $element) {
if (($element['id'] ?? '') === $element_id) return $element;
if (!empty($element['elements'])) {
$found = $this->find_element($element['elements'], $element_id);
if ($found) return $found;
}
}
return null;
}
// -------------------------------------------------------------------------
// Container key normalisation
// -------------------------------------------------------------------------
public function remove_element(array $elements, string $element_id): array {
$result = [];
foreach ($elements as $element) {
if (($element['id'] ?? '') === $element_id) continue;
if (!empty($element['elements'])) {
$element['elements'] = $this->remove_element($element['elements'], $element_id);
}
$result[] = $element;
}
return $result;
}
/**
* Rewrites unprefixed flex shorthand keys to the prefixed keys that
* Elementor's container CSS generator actually reads.
*
* Without this, justify_content / align_items / align_content are persisted
* under names Elementor ignores and the container renders with default
* alignment on the front-end.
*
* Mirrors CONTAINER_KEY_ALIASES + normalize_container_settings() from
* Elementor_MCP_Element_Factory v1.9.0.
*/
private const CONTAINER_KEY_ALIASES = [
'justify_content' => 'flex_justify_content',
'align_items' => 'flex_align_items',
'align_content' => 'flex_align_content',
];
public function update_element_settings(array $elements, string $element_id, array $settings, bool $merge = true): array {
foreach ($elements as &$element) {
if (($element['id'] ?? '') === $element_id) {
$current = $element['settings'] ?? [];
$element['settings'] = $merge ? array_merge($current, $settings) : $settings;
return $elements;
public static function normalize_container_settings(array $settings): array {
foreach (self::CONTAINER_KEY_ALIASES as $shorthand => $flex_key) {
if (!array_key_exists($shorthand, $settings)) continue;
if (!array_key_exists($flex_key, $settings)) {
$settings[$flex_key] = $settings[$shorthand];
}
if (!empty($element['elements'])) {
$element['elements'] = $this->update_element_settings($element['elements'], $element_id, $settings, $merge);
unset($settings[$shorthand]);
}
}
return $elements;
return $settings;
}
}