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:
@@ -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));
|
||||
update_post_meta($post_id, '_elementor_edit_mode', 'builder');
|
||||
|
||||
$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;
|
||||
}
|
||||
if (!empty($element['elements'])) {
|
||||
$element['elements'] = $this->update_element_settings($element['elements'], $element_id, $settings, $merge);
|
||||
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];
|
||||
}
|
||||
unset($settings[$shorthand]);
|
||||
}
|
||||
return $elements;
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user