| 1 |
<?php |
| 2 |
|
| 3 |
namespace Elementor\Modules\Mcp\Abilities; |
| 4 |
|
| 5 |
use Elementor\Core\Base\Document; |
| 6 |
use Elementor\Core\Utils\Collection; |
| 7 |
use Elementor\Core\Utils\Document\Document_Mutator; |
| 8 |
use Elementor\Modules\Components\Circular_Dependency_Validator; |
| 9 |
use Elementor\Modules\Components\Components_Access_Controller; |
| 10 |
use Elementor\Modules\Components\Components_Repository; |
| 11 |
use Elementor\Modules\Components\Documents\Component as Component_Document; |
| 12 |
use Elementor\Modules\Components\Non_Atomic_Widget_Validator; |
| 13 |
use Elementor\Modules\Components\Save_Components_Validator; |
| 14 |
use Elementor\Modules\Mcp\Abilities\Utils\Composition_Compiler; |
| 15 |
use Elementor\Modules\Mcp\Abilities\Utils\Insufficient_Permissions_Error; |
| 16 |
use Elementor\Modules\Mcp\Abilities\Utils\Overridable_Props_Builder; |
| 17 |
use Elementor\Modules\Mcp\Abilities\Utils\Prompt_Loader; |
| 18 |
use Elementor\Modules\Mcp\Events\Mcp_Event_Dispatcher; |
| 19 |
use Elementor\Plugin; |
| 20 |
|
| 21 |
if ( ! defined( 'ABSPATH' ) ) { |
| 22 |
exit; |
| 23 |
} |
| 24 |
|
| 25 |
class Manage_Component_Ability extends Abstract_Ability { |
| 26 |
|
| 27 |
const ACTION_CREATE = 'create'; |
| 28 |
const ACTION_UPDATE = 'update'; |
| 29 |
const ACTION_RENAME = 'rename'; |
| 30 |
const ACTION_ARCHIVE = 'archive'; |
| 31 |
const ACTION_PUBLISH = 'publish'; |
| 32 |
|
| 33 |
private ?Components_Repository $repository; |
| 34 |
|
| 35 |
public function __construct( ?Components_Repository $repository = null ) { |
| 36 |
$this->repository = $repository; |
| 37 |
} |
| 38 |
|
| 39 |
protected function get_ability_id(): string { |
| 40 |
return 'elementor/manage-component'; |
| 41 |
} |
| 42 |
|
| 43 |
protected function get_definition(): Ability_Definition { |
| 44 |
return new Ability_Definition( |
| 45 |
__( 'Manage Elementor Component', 'elementor' ), |
| 46 |
Prompt_Loader::load( 'manage-component' ), |
| 47 |
'elementor', |
| 48 |
$this->get_output_schema(), |
| 49 |
[ |
| 50 |
'annotations' => [ |
| 51 |
'readonly' => false, |
| 52 |
'idempotent' => false, |
| 53 |
'destructive' => true, |
| 54 |
], |
| 55 |
], |
| 56 |
fn() => current_user_can( 'manage_options' ), |
| 57 |
$this->get_input_schema() |
| 58 |
); |
| 59 |
} |
| 60 |
|
| 61 |
public function execute( $input = [] ) { |
| 62 |
$input = is_array( $input ) ? $input : []; |
| 63 |
|
| 64 |
switch ( $input['action'] ?? null ) { |
| 65 |
case self::ACTION_CREATE: |
| 66 |
return $this->handle_create( $input ); |
| 67 |
case self::ACTION_UPDATE: |
| 68 |
return $this->handle_update( $input ); |
| 69 |
case self::ACTION_RENAME: |
| 70 |
return $this->handle_rename( $input ); |
| 71 |
case self::ACTION_ARCHIVE: |
| 72 |
return $this->handle_archive( $input ); |
| 73 |
case self::ACTION_PUBLISH: |
| 74 |
return $this->handle_publish( $input ); |
| 75 |
} |
| 76 |
|
| 77 |
return $this->invalid_input( __( 'action must be one of: create, update, rename, archive, publish.', 'elementor' ) ); |
| 78 |
} |
| 79 |
|
| 80 |
private function handle_create( array $input ) { |
| 81 |
$gate = $this->check_access( self::ACTION_CREATE, fn() => Components_Access_Controller::can_create() ); |
| 82 |
if ( $gate ) { |
| 83 |
return $gate; |
| 84 |
} |
| 85 |
|
| 86 |
$title = isset( $input['title'] ) ? trim( (string) $input['title'] ) : ''; |
| 87 |
if ( strlen( $title ) < 2 ) { |
| 88 |
return $this->invalid_input( __( 'create requires a title of at least 2 characters.', 'elementor' ) ); |
| 89 |
} |
| 90 |
|
| 91 |
$source_result = $this->resolve_create_elements( $input ); |
| 92 |
if ( is_wp_error( $source_result ) ) { |
| 93 |
return $source_result; |
| 94 |
} |
| 95 |
[ 'elements' => $elements, 'warnings' => $warnings ] = $source_result; |
| 96 |
$source_id_map = $source_result['source_id_map'] ?? []; |
| 97 |
|
| 98 |
$settings = []; |
| 99 |
$overridable_error = $this->apply_overridable_props( $elements, $input, $settings, $source_id_map ); |
| 100 |
if ( is_wp_error( $overridable_error ) ) { |
| 101 |
return $overridable_error; |
| 102 |
} |
| 103 |
|
| 104 |
$uid = $this->generate_uid(); |
| 105 |
$item = [ |
| 106 |
'uid' => $uid, |
| 107 |
'title' => $title, |
| 108 |
'elements' => $elements, |
| 109 |
'settings' => $settings, |
| 110 |
]; |
| 111 |
$items = Collection::make( [ $item ] ); |
| 112 |
|
| 113 |
$save_validation = Save_Components_Validator::make( $this->get_repository()->all() )->validate( $items ); |
| 114 |
if ( ! $save_validation['success'] ) { |
| 115 |
return new \WP_Error( |
| 116 |
'components_validation_failed', |
| 117 |
__( 'Validation failed: ', 'elementor' ) . implode( ' ', $save_validation['messages'] ), |
| 118 |
[ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] |
| 119 |
); |
| 120 |
} |
| 121 |
|
| 122 |
$circular_error = $this->validate_circular_dependency( |
| 123 |
fn() => Circular_Dependency_Validator::make()->validate_new_components( $items ) |
| 124 |
); |
| 125 |
if ( $circular_error ) { |
| 126 |
return $circular_error; |
| 127 |
} |
| 128 |
|
| 129 |
$non_atomic_error = $this->validate_atomic_elements( $elements ); |
| 130 |
if ( $non_atomic_error ) { |
| 131 |
return $non_atomic_error; |
| 132 |
} |
| 133 |
|
| 134 |
try { |
| 135 |
$component_id = $this->get_repository()->create( $title, $elements, $this->resolve_status( $input ), $uid, $settings ); |
| 136 |
} catch ( \Exception $e ) { |
| 137 |
return new \WP_Error( 'create_failed', $e->getMessage(), [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] ); |
| 138 |
} |
| 139 |
|
| 140 |
$component = $this->get_repository()->get( $component_id, false ); |
| 141 |
|
| 142 |
$this->emit_component_created_event( $title, $component_id, $elements ); |
| 143 |
|
| 144 |
$response = [ |
| 145 |
'success' => true, |
| 146 |
'component_id' => $component_id, |
| 147 |
'uid' => $uid, |
| 148 |
] + $this->document_links( $component ); |
| 149 |
|
| 150 |
if ( ! empty( $warnings ) ) { |
| 151 |
$response['warnings'] = $warnings; |
| 152 |
} |
| 153 |
|
| 154 |
return $response; |
| 155 |
} |
| 156 |
|
| 157 |
private function handle_update( array $input ) { |
| 158 |
$gate = $this->check_access( self::ACTION_UPDATE, fn() => Components_Access_Controller::can_edit() ); |
| 159 |
if ( $gate ) { |
| 160 |
return $gate; |
| 161 |
} |
| 162 |
|
| 163 |
$component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0; |
| 164 |
if ( $component_id <= 0 ) { |
| 165 |
return $this->invalid_input( __( 'update requires component_id.', 'elementor' ) ); |
| 166 |
} |
| 167 |
|
| 168 |
$component = $this->get_repository()->get_for_edit( $component_id, $this->resolve_status( $input ) ); |
| 169 |
if ( ! $component ) { |
| 170 |
return $this->not_found( __( 'Component not found.', 'elementor' ) ); |
| 171 |
} |
| 172 |
|
| 173 |
$has_xml = ! empty( $input['xml_structure'] ) && is_string( $input['xml_structure'] ); |
| 174 |
|
| 175 |
return $has_xml |
| 176 |
? $this->update_with_xml( $component, $input ) |
| 177 |
: $this->update_overridable_props_only( $component, $input ); |
| 178 |
} |
| 179 |
|
| 180 |
private function handle_rename( array $input ) { |
| 181 |
$gate = $this->check_access( self::ACTION_RENAME, fn() => Components_Access_Controller::can_rename() ); |
| 182 |
if ( $gate ) { |
| 183 |
return $gate; |
| 184 |
} |
| 185 |
|
| 186 |
$component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0; |
| 187 |
$title = isset( $input['title'] ) ? trim( (string) $input['title'] ) : ''; |
| 188 |
|
| 189 |
if ( $component_id <= 0 || strlen( $title ) < 2 ) { |
| 190 |
return $this->invalid_input( __( 'rename requires component_id and a title of at least 2 characters.', 'elementor' ) ); |
| 191 |
} |
| 192 |
|
| 193 |
$success = $this->get_repository()->update_title( $component_id, $title, $this->resolve_status( $input ) ); |
| 194 |
if ( ! $success ) { |
| 195 |
return $this->not_found( __( 'Component not found or title could not be updated.', 'elementor' ) ); |
| 196 |
} |
| 197 |
|
| 198 |
return [ |
| 199 |
'success' => true, |
| 200 |
'component_id' => $component_id, |
| 201 |
]; |
| 202 |
} |
| 203 |
|
| 204 |
private function handle_archive( array $input ) { |
| 205 |
$gate = $this->check_access( self::ACTION_ARCHIVE, fn() => Components_Access_Controller::can_delete() ); |
| 206 |
if ( $gate ) { |
| 207 |
return $gate; |
| 208 |
} |
| 209 |
|
| 210 |
$component_ids = $this->normalize_ids( $input['component_ids'] ?? [] ); |
| 211 |
if ( empty( $component_ids ) ) { |
| 212 |
return $this->invalid_input( __( 'archive requires a non-empty component_ids array of positive integers.', 'elementor' ) ); |
| 213 |
} |
| 214 |
|
| 215 |
$result = $this->get_repository()->archive( $component_ids, $this->resolve_status( $input ) ); |
| 216 |
|
| 217 |
return [ |
| 218 |
'success' => empty( $result['failedIds'] ), |
| 219 |
'success_ids' => $result['successIds'], |
| 220 |
'failed_ids' => $result['failedIds'], |
| 221 |
]; |
| 222 |
} |
| 223 |
|
| 224 |
private function handle_publish( array $input ) { |
| 225 |
$gate = $this->check_access( self::ACTION_PUBLISH, fn() => Components_Access_Controller::can_publish() ); |
| 226 |
if ( $gate ) { |
| 227 |
return $gate; |
| 228 |
} |
| 229 |
|
| 230 |
$component_id = isset( $input['component_id'] ) ? (int) $input['component_id'] : 0; |
| 231 |
if ( $component_id <= 0 ) { |
| 232 |
return $this->invalid_input( __( 'publish requires component_id.', 'elementor' ) ); |
| 233 |
} |
| 234 |
|
| 235 |
$component = $this->get_repository()->get( $component_id ); |
| 236 |
if ( ! $component ) { |
| 237 |
return $this->not_found( __( 'Component not found.', 'elementor' ) ); |
| 238 |
} |
| 239 |
|
| 240 |
if ( ! $this->get_repository()->publish_component( $component ) ) { |
| 241 |
return new \WP_Error( |
| 242 |
'publish_failed', |
| 243 |
__( 'Failed to publish component.', 'elementor' ), |
| 244 |
[ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ] |
| 245 |
); |
| 246 |
} |
| 247 |
|
| 248 |
$published = $this->get_repository()->get( $component->get_main_id(), false ); |
| 249 |
|
| 250 |
return [ |
| 251 |
'success' => true, |
| 252 |
'component_id' => $component->get_main_id(), |
| 253 |
] + $this->document_links( $published ); |
| 254 |
} |
| 255 |
|
| 256 |
private function update_overridable_props_only( Component_Document $component, array $input ) { |
| 257 |
if ( ! is_array( $input['overridable_props'] ?? null ) || empty( $input['overridable_props'] ) ) { |
| 258 |
return $this->invalid_input( __( 'update without xml_structure requires non-empty overridable_props.', 'elementor' ) ); |
| 259 |
} |
| 260 |
|
| 261 |
$elements = $component->get_elements_data(); |
| 262 |
$settings = []; |
| 263 |
|
| 264 |
$overridable_error = $this->apply_overridable_props( $elements, $input, $settings ); |
| 265 |
if ( is_wp_error( $overridable_error ) ) { |
| 266 |
return $overridable_error; |
| 267 |
} |
| 268 |
|
| 269 |
return $this->save_component( $component, $elements, $settings ); |
| 270 |
} |
| 271 |
|
| 272 |
private function update_with_xml( Component_Document $component, array $input ) { |
| 273 |
$compiled = $this->compile_composition( $input, $component ); |
| 274 |
if ( is_wp_error( $compiled ) ) { |
| 275 |
return $compiled; |
| 276 |
} |
| 277 |
|
| 278 |
$elements = $this->assign_element_ids( $compiled['elements'] ); |
| 279 |
$warnings = $compiled['warnings']; |
| 280 |
|
| 281 |
$non_atomic_error = $this->validate_atomic_elements( $elements ); |
| 282 |
if ( $non_atomic_error ) { |
| 283 |
return $non_atomic_error; |
| 284 |
} |
| 285 |
|
| 286 |
$settings = []; |
| 287 |
$overridable_error = $this->apply_overridable_props( $elements, $input, $settings ); |
| 288 |
if ( is_wp_error( $overridable_error ) ) { |
| 289 |
return $overridable_error; |
| 290 |
} |
| 291 |
|
| 292 |
$circular_error = $this->validate_circular_dependency( |
| 293 |
fn() => Circular_Dependency_Validator::make()->validate( $component->get_main_id(), $elements ) |
| 294 |
); |
| 295 |
if ( $circular_error ) { |
| 296 |
return $circular_error; |
| 297 |
} |
| 298 |
|
| 299 |
$result = $this->save_component( $component, $elements, $settings ); |
| 300 |
if ( is_wp_error( $result ) || empty( $warnings ) ) { |
| 301 |
return $result; |
| 302 |
} |
| 303 |
|
| 304 |
return $result + [ 'warnings' => $warnings ]; |
| 305 |
} |
| 306 |
|
| 307 |
private function save_component( Component_Document $component, array $elements, array $settings ) { |
| 308 |
try { |
| 309 |
$saved = $component->save( [ |
| 310 |
'elements' => $elements, |
| 311 |
'settings' => $settings, |
| 312 |
] ); |
| 313 |
} catch ( \Exception $e ) { |
| 314 |
if ( str_contains( $e->getMessage(), 'components that contain each other' ) ) { |
| 315 |
return new \WP_Error( |
| 316 |
'circular_dependency_detected', |
| 317 |
__( "Can't add this component - components that contain each other can't be nested.", 'elementor' ), |
| 318 |
[ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] |
| 319 |
); |
| 320 |
} |
| 321 |
|
| 322 |
return new \WP_Error( 'save_failed', $e->getMessage(), [ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] ); |
| 323 |
} |
| 324 |
|
| 325 |
if ( ! $saved ) { |
| 326 |
return new \WP_Error( 'save_failed', __( 'Failed to save component.', 'elementor' ), [ 'status' => \WP_Http::INTERNAL_SERVER_ERROR ] ); |
| 327 |
} |
| 328 |
|
| 329 |
return [ |
| 330 |
'success' => true, |
| 331 |
'component_id' => $component->get_main_id(), |
| 332 |
] + $this->document_links( $component ); |
| 333 |
} |
| 334 |
|
| 335 |
private function validate_circular_dependency( callable $validate ): ?\WP_Error { |
| 336 |
$circular_validation = $validate(); |
| 337 |
|
| 338 |
if ( $circular_validation['success'] ) { |
| 339 |
return null; |
| 340 |
} |
| 341 |
|
| 342 |
return new \WP_Error( |
| 343 |
'circular_dependency_detected', |
| 344 |
__( "Can't add this component - components that contain each other can't be nested.", 'elementor' ), |
| 345 |
[ |
| 346 |
'status' => \WP_Http::UNPROCESSABLE_ENTITY, |
| 347 |
'caused_by' => $circular_validation['messages'], |
| 348 |
] |
| 349 |
); |
| 350 |
} |
| 351 |
|
| 352 |
private function validate_atomic_elements( array $elements ): ?\WP_Error { |
| 353 |
$validation = Non_Atomic_Widget_Validator::make()->validate( $elements ); |
| 354 |
|
| 355 |
if ( $validation['success'] ) { |
| 356 |
return null; |
| 357 |
} |
| 358 |
|
| 359 |
return new \WP_Error( |
| 360 |
Non_Atomic_Widget_Validator::ERROR_CODE, |
| 361 |
__( 'Components require atomic elements only. Remove widgets to create this component.', 'elementor' ), |
| 362 |
[ |
| 363 |
'status' => \WP_Http::UNPROCESSABLE_ENTITY, |
| 364 |
'non_atomic_elements' => $validation['non_atomic_elements'], |
| 365 |
] |
| 366 |
); |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* @return array{elements: array[], warnings: string[], source_id_map?: array<string,string>}|\WP_Error |
| 371 |
*/ |
| 372 |
private function resolve_create_elements( array $input ) { |
| 373 |
$has_xml = ! empty( $input['xml_structure'] ) && is_string( $input['xml_structure'] ); |
| 374 |
$has_source_element = ! empty( $input['source_post_id'] ) && ! empty( $input['element_id'] ); |
| 375 |
|
| 376 |
if ( $has_xml && $has_source_element ) { |
| 377 |
return $this->invalid_input( __( 'create accepts either xml_structure or source_post_id/element_id, not both.', 'elementor' ) ); |
| 378 |
} |
| 379 |
|
| 380 |
if ( $has_xml ) { |
| 381 |
return $this->compile_elements_from_xml( $input ); |
| 382 |
} |
| 383 |
|
| 384 |
if ( $has_source_element ) { |
| 385 |
return $this->copy_elements_from_source( $input ); |
| 386 |
} |
| 387 |
|
| 388 |
return [ |
| 389 |
'elements' => [], |
| 390 |
'warnings' => [], |
| 391 |
]; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* @return array{elements: array[], warnings: string[]}|\WP_Error |
| 396 |
*/ |
| 397 |
private function compile_elements_from_xml( array $input ) { |
| 398 |
$compiled = $this->compile_composition( $input ); |
| 399 |
|
| 400 |
if ( is_wp_error( $compiled ) ) { |
| 401 |
return $compiled; |
| 402 |
} |
| 403 |
|
| 404 |
return [ |
| 405 |
'elements' => $this->assign_element_ids( $compiled['elements'] ), |
| 406 |
'warnings' => $compiled['warnings'], |
| 407 |
]; |
| 408 |
} |
| 409 |
|
| 410 |
/** |
| 411 |
* @return array{elements: array[], warnings: string[], dom: \DOMDocument, xml_parser: \Elementor\Modules\Mcp\Abilities\Build_Composition\Xml_Parser}|\WP_Error |
| 412 |
*/ |
| 413 |
private function compile_composition( array $input, ?Document $document = null ) { |
| 414 |
$compiled = Composition_Compiler::make()->compile( |
| 415 |
$input, |
| 416 |
$document, |
| 417 |
[], |
| 418 |
Composition_Compiler::COMPONENT_PARENT_ID |
| 419 |
); |
| 420 |
if ( is_wp_error( $compiled ) ) { |
| 421 |
return $compiled; |
| 422 |
} |
| 423 |
|
| 424 |
if ( 1 !== count( $compiled['elements'] ) ) { |
| 425 |
return new \WP_Error( |
| 426 |
'component_requires_single_root', |
| 427 |
__( 'A component composition must contain exactly one root element.', 'elementor' ), |
| 428 |
[ 'status' => \WP_Http::UNPROCESSABLE_ENTITY ] |
| 429 |
); |
| 430 |
} |
| 431 |
|
| 432 |
return $compiled; |
| 433 |
} |
| 434 |
|
| 435 |
/** |
| 436 |
* @return array{elements: array[], warnings: string[], source_id_map: array<string,string>}|\WP_Error |
| 437 |
*/ |
| 438 |
private function copy_elements_from_source( array $input ) { |
| 439 |
$source_post_id = (int) $input['source_post_id']; |
| 440 |
$element_id = (string) $input['element_id']; |
| 441 |
|
| 442 |
if ( ! current_user_can( 'edit_post', $source_post_id ) ) { |
| 443 |
return new \WP_Error( |
| 444 |
'elementor_forbidden', |
| 445 |
__( 'You do not have permission to read source_post_id.', 'elementor' ), |
| 446 |
[ 'status' => \WP_Http::FORBIDDEN ] |
| 447 |
); |
| 448 |
} |
| 449 |
|
| 450 |
$document = Plugin::$instance->documents->get_doc_or_auto_save( $source_post_id, get_current_user_id() ) |
| 451 |
?? Plugin::$instance->documents->get( $source_post_id ); |
| 452 |
|
| 453 |
if ( ! $document ) { |
| 454 |
return $this->not_found( __( 'source_post_id not found.', 'elementor' ) ); |
| 455 |
} |
| 456 |
|
| 457 |
$found = Document_Mutator::instance()->find_by_id( $document->get_elements_data(), $element_id ); |
| 458 |
if ( null === $found ) { |
| 459 |
return $this->not_found( __( 'element_id was not found on source_post_id.', 'elementor' ) ); |
| 460 |
} |
| 461 |
|
| 462 |
$source_id_map = []; |
| 463 |
$elements = $this->assign_element_ids_recording_source_ids( [ $found ], $source_id_map ); |
| 464 |
|
| 465 |
return [ |
| 466 |
'elements' => $elements, |
| 467 |
'warnings' => [], |
| 468 |
'source_id_map' => $source_id_map, |
| 469 |
]; |
| 470 |
} |
| 471 |
|
| 472 |
/** |
| 473 |
* Builds the native overridable-props payload and stamps `overridable` envelopes |
| 474 |
* onto the referenced element settings. Validation and persistence of the native |
| 475 |
* payload itself is left to the component document's save hook, which already |
| 476 |
* parses and persists it for every component save. |
| 477 |
* |
| 478 |
* @param array[] $elements |
| 479 |
* @param array $input |
| 480 |
* @param array $settings |
| 481 |
* @param array<string,string> $source_id_map old-source-id => new-machine-id, when the tree |
| 482 |
* came from `copy_elements_from_source`. Used to |
| 483 |
* let callers address targets by the ids they |
| 484 |
* saw on the source document, before id regeneration. |
| 485 |
* |
| 486 |
* @return \WP_Error|null |
| 487 |
*/ |
| 488 |
private function apply_overridable_props( array &$elements, array $input, array &$settings, array $source_id_map = [] ) { |
| 489 |
if ( ! is_array( $input['overridable_props'] ?? null ) || empty( $input['overridable_props'] ) ) { |
| 490 |
return null; |
| 491 |
} |
| 492 |
|
| 493 |
$definitions = $this->remap_overridable_targets( $input['overridable_props'], $source_id_map ); |
| 494 |
|
| 495 |
$builder_result = Overridable_Props_Builder::make( $this->get_repository() )->build( $elements, $definitions ); |
| 496 |
if ( is_wp_error( $builder_result ) ) { |
| 497 |
return $builder_result; |
| 498 |
} |
| 499 |
|
| 500 |
$settings['overridable_props'] = $builder_result; |
| 501 |
|
| 502 |
return null; |
| 503 |
} |
| 504 |
|
| 505 |
/** |
| 506 |
* Rewrites `overridable_props[*].target` from source-document ids to the freshly assigned |
| 507 |
* ids, so callers can keep referencing the elements by the ids they observed on the source. |
| 508 |
* Unknown targets pass through untouched so the xml_structure `configuration-id` path (which |
| 509 |
* resolves via `editor_settings.title` in `Overridable_Props_Builder::find_element_ref`) and |
| 510 |
* regular id targets still work. |
| 511 |
* |
| 512 |
* @param array $definitions |
| 513 |
* @param array<string,string> $source_id_map |
| 514 |
*/ |
| 515 |
private function remap_overridable_targets( array $definitions, array $source_id_map ): array { |
| 516 |
if ( empty( $source_id_map ) ) { |
| 517 |
return $definitions; |
| 518 |
} |
| 519 |
|
| 520 |
foreach ( $definitions as $override_key => &$definition ) { |
| 521 |
if ( ! is_array( $definition ) ) { |
| 522 |
continue; |
| 523 |
} |
| 524 |
$target = $definition['target'] ?? null; |
| 525 |
if ( is_string( $target ) && isset( $source_id_map[ $target ] ) ) { |
| 526 |
$definition['target'] = $source_id_map[ $target ]; |
| 527 |
} |
| 528 |
} |
| 529 |
|
| 530 |
return $definitions; |
| 531 |
} |
| 532 |
|
| 533 |
private function assign_element_ids( array $elements ): array { |
| 534 |
return array_map( fn( array $element ) => $this->assign_element_id( $element ), $elements ); |
| 535 |
} |
| 536 |
|
| 537 |
private function assign_element_id( array $element ): array { |
| 538 |
$element['id'] = Document_Mutator::instance()->generate_id(); |
| 539 |
|
| 540 |
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) { |
| 541 |
$element['elements'] = array_map( fn( array $child ) => $this->assign_element_id( $child ), $element['elements'] ); |
| 542 |
} |
| 543 |
|
| 544 |
return $element; |
| 545 |
} |
| 546 |
|
| 547 |
/** |
| 548 |
* Regenerates ids like `assign_element_ids`, but records the mapping from each element's |
| 549 |
* old id to its new id so callers can keep addressing the tree by pre-regeneration ids. |
| 550 |
* |
| 551 |
* @param array[] $elements |
| 552 |
* @param array<string,string> $source_id_map Populated by reference. |
| 553 |
*/ |
| 554 |
private function assign_element_ids_recording_source_ids( array $elements, array &$source_id_map ): array { |
| 555 |
$result = []; |
| 556 |
foreach ( $elements as $element ) { |
| 557 |
$result[] = $this->assign_element_id_recording_source_id( $element, $source_id_map ); |
| 558 |
} |
| 559 |
return $result; |
| 560 |
} |
| 561 |
|
| 562 |
private function assign_element_id_recording_source_id( array $element, array &$source_id_map ): array { |
| 563 |
$old_id = isset( $element['id'] ) ? (string) $element['id'] : ''; |
| 564 |
|
| 565 |
$element['id'] = Document_Mutator::instance()->generate_id(); |
| 566 |
|
| 567 |
if ( '' !== $old_id ) { |
| 568 |
$source_id_map[ $old_id ] = $element['id']; |
| 569 |
} |
| 570 |
|
| 571 |
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) { |
| 572 |
$element['elements'] = $this->assign_element_ids_recording_source_ids( $element['elements'], $source_id_map ); |
| 573 |
} |
| 574 |
|
| 575 |
return $element; |
| 576 |
} |
| 577 |
|
| 578 |
private function document_links( Component_Document $component ): array { |
| 579 |
$editor_url = $component->get_edit_url(); |
| 580 |
|
| 581 |
return [ |
| 582 |
'edit_url' => $editor_url, |
| 583 |
'llm_instructions' => sprintf( |
| 584 |
/* translators: %s: Component editor URL. */ |
| 585 |
__( 'You MUST show the user this link to review the component: %s', 'elementor' ), |
| 586 |
$editor_url |
| 587 |
), |
| 588 |
]; |
| 589 |
} |
| 590 |
|
| 591 |
private function resolve_status( array $input ): string { |
| 592 |
return 'draft' === ( $input['publish_status'] ?? 'publish' ) ? Document::STATUS_DRAFT : Document::STATUS_PUBLISH; |
| 593 |
} |
| 594 |
|
| 595 |
private function generate_uid(): string { |
| 596 |
return 'component-' . wp_generate_uuid4(); |
| 597 |
} |
| 598 |
|
| 599 |
private function normalize_ids( $raw ): array { |
| 600 |
if ( ! is_array( $raw ) ) { |
| 601 |
return []; |
| 602 |
} |
| 603 |
|
| 604 |
$ids = []; |
| 605 |
foreach ( $raw as $value ) { |
| 606 |
$id = (int) $value; |
| 607 |
if ( $id > 0 ) { |
| 608 |
$ids[ $id ] = $id; |
| 609 |
} |
| 610 |
} |
| 611 |
|
| 612 |
return array_values( $ids ); |
| 613 |
} |
| 614 |
|
| 615 |
private function check_access( string $action, callable $can ): ?\WP_Error { |
| 616 |
return $can() ? null : Insufficient_Permissions_Error::for_action( $action ); |
| 617 |
} |
| 618 |
|
| 619 |
private function invalid_input( string $message ): \WP_Error { |
| 620 |
return new \WP_Error( 'invalid_input', $message, [ 'status' => \WP_Http::BAD_REQUEST ] ); |
| 621 |
} |
| 622 |
|
| 623 |
private function not_found( string $message ): \WP_Error { |
| 624 |
return new \WP_Error( 'elementor_not_found', $message, [ 'status' => \WP_Http::NOT_FOUND ] ); |
| 625 |
} |
| 626 |
|
| 627 |
private function get_repository(): Components_Repository { |
| 628 |
if ( ! $this->repository ) { |
| 629 |
$this->repository = new Components_Repository(); |
| 630 |
} |
| 631 |
|
| 632 |
return $this->repository; |
| 633 |
} |
| 634 |
|
| 635 |
private function get_output_schema(): array { |
| 636 |
return [ |
| 637 |
'type' => 'object', |
| 638 |
'properties' => [ |
| 639 |
'success' => [ 'type' => 'boolean' ], |
| 640 |
'component_id' => [ 'type' => 'integer' ], |
| 641 |
'uid' => [ |
| 642 |
'type' => 'string', |
| 643 |
'description' => 'Server-generated component uid, only present for create.', |
| 644 |
], |
| 645 |
'success_ids' => [ |
| 646 |
'type' => 'array', |
| 647 |
'items' => [ 'type' => 'integer' ], |
| 648 |
'description' => 'archive only.', |
| 649 |
], |
| 650 |
'failed_ids' => [ |
| 651 |
'type' => 'array', |
| 652 |
'items' => [ 'type' => 'integer' ], |
| 653 |
'description' => 'archive only.', |
| 654 |
], |
| 655 |
'edit_url' => [ |
| 656 |
'type' => 'string', |
| 657 |
'description' => 'Component documents have no public permalink; this is the editor URL to review the change.', |
| 658 |
], |
| 659 |
'llm_instructions' => [ |
| 660 |
'type' => 'string', |
| 661 |
'description' => 'Mandatory next step: include this text (with the editor link) in your reply to the user.', |
| 662 |
], |
| 663 |
'warnings' => [ |
| 664 |
'type' => 'array', |
| 665 |
'items' => [ 'type' => 'string' ], |
| 666 |
'description' => 'Non-fatal notices from XML compilation, e.g. props skipped or CSS that fell back to custom_css.', |
| 667 |
], |
| 668 |
], |
| 669 |
]; |
| 670 |
} |
| 671 |
|
| 672 |
private function get_input_schema(): array { |
| 673 |
return [ |
| 674 |
'type' => 'object', |
| 675 |
'required' => [ 'action' ], |
| 676 |
'properties' => [ |
| 677 |
'action' => [ |
| 678 |
'type' => 'string', |
| 679 |
'enum' => [ self::ACTION_CREATE, self::ACTION_UPDATE, self::ACTION_RENAME, self::ACTION_ARCHIVE, self::ACTION_PUBLISH ], |
| 680 |
], |
| 681 |
'title' => [ |
| 682 |
'type' => 'string', |
| 683 |
'minLength' => 2, |
| 684 |
'maxLength' => 200, |
| 685 |
'description' => 'create: component title. rename: the new title.', |
| 686 |
], |
| 687 |
'source_post_id' => [ |
| 688 |
'type' => 'integer', |
| 689 |
'description' => 'create: WordPress post id to copy an existing element subtree from. Requires element_id. Mutually exclusive with xml_structure.', |
| 690 |
], |
| 691 |
'element_id' => [ |
| 692 |
'type' => 'string', |
| 693 |
'description' => 'create: id of the element (within source_post_id, from elementor/get-page-structure) to copy as the component root.', |
| 694 |
], |
| 695 |
'xml_structure' => [ |
| 696 |
'type' => 'string', |
| 697 |
'description' => 'create/update: same tag language as elementor/build-composition, with exactly one root element. Mutually exclusive with source_post_id/element_id.', |
| 698 |
], |
| 699 |
'element_config' => [ |
| 700 |
'type' => 'object', |
| 701 |
'default' => (object) [], |
| 702 |
'description' => 'Same shape as elementor/build-composition element_config. Only used with xml_structure.', |
| 703 |
], |
| 704 |
'classes' => [ |
| 705 |
'type' => 'object', |
| 706 |
'default' => (object) [], |
| 707 |
'description' => 'Same shape as elementor/build-composition classes. Only used with xml_structure.', |
| 708 |
], |
| 709 |
'style' => [ |
| 710 |
'type' => 'object', |
| 711 |
'default' => (object) [], |
| 712 |
'description' => 'Same shape as elementor/build-composition style. Only used with xml_structure.', |
| 713 |
], |
| 714 |
'interactions' => [ |
| 715 |
'type' => 'object', |
| 716 |
'default' => (object) [], |
| 717 |
'description' => 'Same shape as elementor/build-composition interactions. Only used with xml_structure.', |
| 718 |
], |
| 719 |
'overridable_props' => [ |
| 720 |
'type' => 'object', |
| 721 |
'description' => 'Caller-supplied record mapping override-key → { target, prop_key, label, group? }. target identifies the element to expose: for xml_structure, use the same configuration-id you set on that element; for source_post_id/element_id (create) or an update without xml_structure, use the real element id (from elementor/get-page-structure). Group IDs and origin values are generated server-side.', |
| 722 |
], |
| 723 |
'publish_status' => [ |
| 724 |
'type' => 'string', |
| 725 |
'enum' => [ 'publish', 'draft' ], |
| 726 |
'default' => 'publish', |
| 727 |
'description' => 'create: initial document status. update/rename/archive: "draft" edits an autosave copy instead of the live document; the change is not visible on pages using this component until you publish it.', |
| 728 |
], |
| 729 |
'component_id' => [ |
| 730 |
'type' => 'integer', |
| 731 |
'description' => 'update/rename/publish target component id.', |
| 732 |
], |
| 733 |
'component_ids' => [ |
| 734 |
'type' => 'array', |
| 735 |
'items' => [ 'type' => 'integer' ], |
| 736 |
'description' => 'archive targets.', |
| 737 |
], |
| 738 |
], |
| 739 |
]; |
| 740 |
} |
| 741 |
|
| 742 |
private function emit_component_created_event( string $title, int $component_id, array $elements ): void { |
| 743 |
[ 'elements_count' => $nested_elements_count, 'components_count' => $nested_components_count ] = $this->count_nested_elements( $elements ); |
| 744 |
$top_element_type = $elements[0]['elType'] ?? ( $elements[0]['widgetType'] ?? '' ); |
| 745 |
|
| 746 |
Mcp_Event_Dispatcher::emit( 'component_created', [ |
| 747 |
'id' => (string) $component_id, |
| 748 |
'name' => $title, |
| 749 |
'nested_elements_count' => $nested_elements_count, |
| 750 |
'nested_components_count' => $nested_components_count, |
| 751 |
'top_element_type' => $top_element_type, |
| 752 |
] ); |
| 753 |
} |
| 754 |
|
| 755 |
private function count_nested_elements( array $elements ): array { |
| 756 |
$elements_count = count( $elements ); |
| 757 |
$components_count = 0; |
| 758 |
|
| 759 |
foreach ( $elements as $element ) { |
| 760 |
if ( 'e-component' === ( $element['widgetType'] ?? '' ) ) { |
| 761 |
$components_count++; |
| 762 |
} |
| 763 |
|
| 764 |
$children = $element['elements'] ?? []; |
| 765 |
|
| 766 |
if ( ! empty( $children ) ) { |
| 767 |
$child_counts = $this->count_nested_elements( $children ); |
| 768 |
$elements_count += $child_counts['elements_count']; |
| 769 |
$components_count += $child_counts['components_count']; |
| 770 |
} |
| 771 |
} |
| 772 |
|
| 773 |
return [ |
| 774 |
'elements_count' => $elements_count, |
| 775 |
'components_count' => $components_count, |
| 776 |
]; |
| 777 |
} |
| 778 |
} |
| 779 |
|