PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0-beta3
Elementor Website Builder – more than just a page builder v4.3.0-beta3
4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 All 452 releases
elementor / modules / mcp / abilities / manage-classes-ability.php

manage-classes-ability.php in Elementor Website Builder – more than just a page builder 4.3.0-beta3, at modules/mcp/abilities/manage-classes-ability.php

717 lines 23.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Modules\Mcp\Abilities;
4
5 use Elementor\Modules\AtomicWidgets\CssConverter\Converter_Registry_Factory;
6 use Elementor\Modules\AtomicWidgets\CssConverter\Css_Converter;
7 use Elementor\Modules\AtomicWidgets\CssConverter\Expander_Registry_Factory;
8 use Elementor\Modules\AtomicWidgets\CssConverter\Metrics\Null_Failure_Reporter;
9 use Elementor\Modules\AtomicWidgets\CssConverter\Variable_Prop_Value_Transformer;
10 use Elementor\Modules\AtomicWidgets\Module as AtomicWidgetsModule;
11 use Elementor\Modules\AtomicWidgets\Utils\Utils;
12 use Elementor\Modules\GlobalClasses\Database\Migrations\Add_Capabilities;
13 use Elementor\Modules\GlobalClasses\Global_Classes_Labels;
14 use Elementor\Modules\GlobalClasses\Global_Classes_Repository;
15 use Elementor\Modules\GlobalClasses\Global_Classes_REST_API;
16 use Elementor\Modules\Mcp\Abilities\Utils\Bulk_Operations_Result;
17 use Elementor\Modules\Mcp\Abilities\Utils\Style_Variants_Merger;
18 use Elementor\Modules\Mcp\Abilities\Utils\Tool_Performance_Metrics;
19 use Elementor\Modules\Mcp\Events\Mcp_Event_Dispatcher;
20 use Elementor\Modules\Variables\Module as Variables_Module;
21 use Elementor\Modules\Variables\Services\Batch_Operations\Batch_Processor;
22 use Elementor\Modules\Variables\Services\Variables_Service;
23 use Elementor\Modules\Variables\Storage\Variables_Repository;
24 use Elementor\Plugin;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 class Manage_Classes_Ability extends Abstract_Ability {
31
32 const CLASS_TYPE = 'class';
33 const DESKTOP_BREAKPOINT = 'desktop';
34 const MAX_BATCH_SIZE = 50;
35
36 private ?Global_Classes_Repository $repository;
37 private ?Css_Converter $css_converter;
38
39 public function __construct( ?Global_Classes_Repository $repository = null, ?Css_Converter $css_converter = null ) {
40 $this->repository = $repository;
41 $this->css_converter = $css_converter;
42 }
43
44 protected function get_ability_id(): string {
45 return 'elementor/manage-classes';
46 }
47
48 protected function get_definition(): Ability_Definition {
49 return new Ability_Definition(
50 __( 'Manage Global Classes', 'elementor' ),
51 __( 'Manage V4 global CSS classes on the active kit. Bulk create, update, or delete using raw CSS declarations (up to 50 operations). Duplicate labels are auto-renamed with a DUP_ prefix.', 'elementor' ),
52 'elementor',
53 [
54 'type' => 'object',
55 'required' => [ 'status', 'results', 'order' ],
56 'properties' => [
57 'status' => [ 'type' => 'string' ],
58 'results' => [ 'type' => 'array' ],
59 'order' => [
60 'type' => 'array',
61 'items' => [ 'type' => 'string' ],
62 ],
63 ],
64 ],
65 [
66 'annotations' => [
67 'readonly' => false,
68 'idempotent' => false,
69 'destructive' => false,
70 ],
71 ],
72 fn() => current_user_can( Add_Capabilities::UPDATE_CLASS ),
73 [
74 'type' => 'object',
75 'required' => [ 'operations' ],
76 'properties' => [
77 'operations' => [
78 'type' => 'array',
79 'description' => 'Bulk operations (1–50). Each item requires action. create needs label and css. update/delete need id or label (both unique identifiers; label is also updated if provided on update). Use mode to control merge behaviour on update (patch = upsert, replace = overwrite breakpoint).',
80 'items' => [
81 'type' => 'object',
82 'required' => [ 'action' ],
83 'properties' => [
84 'action' => [
85 'type' => 'string',
86 'enum' => [ 'create', 'update', 'delete' ],
87 ],
88 'id' => [ 'type' => 'string' ],
89 'label' => [ 'type' => 'string' ],
90 'css' => [
91 'type' => 'string',
92 'description' => 'Plain CSS string. Supports &:hover/&:focus/&:active nesting and @media(--breakpoint) blocks. In patch mode: "prop: null" removes that prop; "all: null" wipes the variant.',
93 ],
94 'mode' => [
95 'type' => 'string',
96 'enum' => [ 'patch', 'replace' ],
97 'default' => 'patch',
98 'description' => 'patch (default): upsert variants, preserving untouched ones; null/all:null deletions apply. replace: discard all variants for the affected breakpoints, then store new ones; null values have no effect.',
99 ],
100 ],
101 ],
102 ],
103 ],
104 ]
105 );
106 }
107
108 public function execute( $input = [] ) {
109 $started_at = hrtime( true );
110 $input = is_array( $input ) ? $input : [];
111 $operations = $input['operations'] ?? null;
112
113 if ( ! is_array( $operations ) ) {
114 $error = $this->bad_request( __( 'operations array is required.', 'elementor' ) );
115 $this->emit_mcp_manage_classes_executed( $started_at, [], $error );
116 return $error;
117 }
118
119 if ( empty( $operations ) ) {
120 $error = $this->bad_request( __( 'operations must not be empty.', 'elementor' ) );
121 $this->emit_mcp_manage_classes_executed( $started_at, [], $error );
122 return $error;
123 }
124
125 if ( count( $operations ) > self::MAX_BATCH_SIZE ) {
126 $error = new \WP_Error(
127 'batch_size_exceeded',
128 sprintf(
129 /* translators: %d: maximum operations per request */
130 __( 'Maximum %d operations per request.', 'elementor' ),
131 self::MAX_BATCH_SIZE
132 ),
133 [
134 'status' => \WP_Http::BAD_REQUEST,
135 'max_allowed' => self::MAX_BATCH_SIZE,
136 ]
137 );
138 $this->emit_mcp_manage_classes_executed( $started_at, $operations, $error );
139 return $error;
140 }
141
142 $response = $this->handle_bulk( $operations );
143 $this->emit_mcp_manage_classes_executed( $started_at, $operations, null, $response );
144 return $response;
145 }
146
147 private function handle_bulk( array $operations ): array {
148 $results = new Bulk_Operations_Result();
149 $repo = $this->get_repository();
150 $all_labels = $repo->all_labels();
151 $current_order = $repo->get_order();
152
153 $intents = $this->translate_operations( $operations, $all_labels, $current_order, $results );
154
155 $touched_items = $this->build_touched_items( $intents, $all_labels, $results );
156
157 $this->handle_duplicated_labels( $intents, $touched_items, $all_labels, $results );
158
159 $this->enforce_max_items( $intents, $touched_items, $all_labels, $results );
160
161 $added_ids = array_values( array_intersect( $intents['added_ids'], array_keys( $touched_items ) ) );
162 $modified_ids = array_values( array_intersect( $intents['modified_ids'], array_keys( $touched_items ) ) );
163 $deleted_ids = $intents['deleted_ids'];
164
165 $new_order = $this->compute_new_order( $current_order, $added_ids, $deleted_ids );
166
167 // NOTE: null_props is an internal patch-merge signal; strip it from all variants before persisting.
168 foreach ( $touched_items as &$item ) {
169 foreach ( $item['variants'] as &$variant ) {
170 unset( $variant['null_props'] );
171 }
172 }
173 unset( $item, $variant );
174
175 if ( ! empty( $added_ids ) || ! empty( $modified_ids ) || ! empty( $deleted_ids ) ) {
176 $repo->apply_changes(
177 $touched_items,
178 [
179 'added' => $added_ids,
180 'deleted' => $deleted_ids,
181 'modified' => $modified_ids,
182 'order' => $new_order !== $current_order,
183 ],
184 $new_order
185 );
186
187 $this->clear_cache();
188 $this->emit_class_created_events( $results );
189 }
190
191 return $results->to_array() + [ 'order' => $new_order ];
192 }
193
194 private function translate_operations( array $operations, array $all_labels, array $current_order, Bulk_Operations_Result $results ): array {
195 $intents = [
196 'creates' => [],
197 'updates' => [],
198 'deletes' => [],
199 'added_ids' => [],
200 'modified_ids' => [],
201 'deleted_ids' => [],
202 ];
203
204 $reserved_ids = array_keys( $all_labels );
205 $deleted_set = [];
206
207 foreach ( $operations as $index => $operation ) {
208 if ( ! is_array( $operation ) ) {
209 $results->add_error( $index, '', 'invalid_input', __( 'Invalid operation.', 'elementor' ) );
210 continue;
211 }
212
213 $action = $operation['action'] ?? '';
214
215 switch ( $action ) {
216 case 'create':
217 $this->translate_create( $index, $operation, $intents, $reserved_ids, $results );
218 break;
219
220 case 'update':
221 $this->translate_update( $index, $operation, $intents, $all_labels, $current_order, $deleted_set, $results );
222 break;
223
224 case 'delete':
225 $this->translate_delete( $index, $operation, $intents, $all_labels, $current_order, $deleted_set, $results );
226 break;
227
228 default:
229 $results->add_error(
230 $index,
231 $action,
232 'invalid_input',
233 sprintf(
234 /* translators: %s: action name */
235 __( 'Unknown action: %s.', 'elementor' ),
236 $action
237 )
238 );
239 }
240 }
241
242 return $intents;
243 }
244
245 private function translate_create( int $index, array $operation, array &$intents, array &$reserved_ids, Bulk_Operations_Result $results ): void {
246 $label = $operation['label'] ?? '';
247 $css_string = $operation['css'] ?? null;
248
249 if ( '' === $label ) {
250 $results->add_error( $index, 'create', 'invalid_input', __( 'Create requires label.', 'elementor' ) );
251 return;
252 }
253
254 if ( ! is_string( $css_string ) || '' === trim( $css_string ) ) {
255 $results->add_error( $index, 'create', 'invalid_input', __( 'Create requires label and css.', 'elementor' ) );
256 return;
257 }
258
259 $parsed = Style_Variants_Merger::parse_css_string( $css_string, $this->get_active_breakpoint_keys(), $index, 'create', $results, fn() => $this->get_css_converter() );
260 if ( null === $parsed ) {
261 return;
262 }
263
264 $class_id = Utils::generate_id( 'g-', $reserved_ids );
265 $reserved_ids[] = $class_id;
266
267 $intents['creates'][ $index ] = [
268 'id' => $class_id,
269 'label' => $label,
270 'breakpoint_blocks' => $parsed['breakpoint_blocks'],
271 ];
272 $intents['added_ids'][] = $class_id;
273 }
274
275 private function translate_update( int $index, array $operation, array &$intents, array $all_labels, array $current_order, array $deleted_set, Bulk_Operations_Result $results ): void {
276 $id = $operation['id'] ?? '';
277 $label = $operation['label'] ?? '';
278
279 if ( '' === $id && '' === $label ) {
280 $results->add_error( $index, 'update', 'invalid_input', __( 'Update requires id or label.', 'elementor' ) );
281 return;
282 }
283
284 if ( '' === $id ) {
285 $id = $this->resolve_class_id_by_label( $label, $all_labels );
286 if ( null === $id ) {
287 $results->add_error( $index, 'update', 'class_not_found', __( 'Global class not found', 'elementor' ) );
288 return;
289 }
290 }
291
292 if ( '' === $label ) {
293 $label = $all_labels[ $id ] ?? '';
294 }
295
296 $mode = $operation['mode'] ?? 'patch';
297 if ( ! in_array( $mode, [ 'patch', 'replace' ], true ) ) {
298 $results->add_error( $index, 'update', 'invalid_input', sprintf( 'Unknown mode: %s. Valid modes: patch, replace.', $mode ) );
299 return;
300 }
301
302 if ( ! in_array( $id, $current_order, true ) || isset( $deleted_set[ $id ] ) ) {
303 $results->add_error( $index, 'update', 'class_not_found', __( 'Global class not found', 'elementor' ) );
304 return;
305 }
306
307 $existing = $this->get_repository()->get( $id );
308 if ( ! $existing ) {
309 $results->add_error( $index, 'update', 'class_not_found', __( 'Global class not found', 'elementor' ) );
310 return;
311 }
312
313 $css_string = $operation['css'] ?? null;
314
315 if ( is_string( $css_string ) && '' !== trim( $css_string ) ) {
316 $parsed = Style_Variants_Merger::parse_css_string( $css_string, $this->get_active_breakpoint_keys(), $index, 'update', $results, fn() => $this->get_css_converter() );
317 if ( null === $parsed ) {
318 return;
319 }
320
321 $intents['updates'][ $index ] = [
322 'id' => $id,
323 'label' => $label,
324 'breakpoint_blocks' => $parsed['breakpoint_blocks'],
325 'removal_breakpoints' => $parsed['removal_breakpoints'],
326 'mode' => $operation['mode'] ?? 'patch',
327 'existing_variants' => $existing['variants'] ?? [],
328 ];
329 } else {
330 $intents['updates'][ $index ] = [
331 'id' => $id,
332 'label' => $label,
333 'mode' => $operation['mode'] ?? 'patch',
334 'existing_variants' => $existing['variants'] ?? [],
335 ];
336 }
337
338 if ( ! in_array( $id, $intents['modified_ids'], true ) ) {
339 $intents['modified_ids'][] = $id;
340 }
341 }
342
343 protected function get_active_breakpoint_keys(): array {
344 return Plugin::$instance->breakpoints->get_active_devices_list();
345 }
346
347 private function translate_delete( int $index, array $operation, array &$intents, array $all_labels, array $current_order, array &$deleted_set, Bulk_Operations_Result $results ): void {
348 $id = $operation['id'] ?? '';
349 $label = $operation['label'] ?? '';
350
351 if ( '' === $id && '' === $label ) {
352 $results->add_error( $index, 'delete', 'invalid_input', __( 'Delete requires id or label.', 'elementor' ) );
353 return;
354 }
355
356 if ( '' === $id ) {
357 $id = $this->resolve_class_id_by_label( $label, $all_labels );
358 if ( null === $id ) {
359 $results->add_error( $index, 'delete', 'class_not_found', __( 'Global class not found', 'elementor' ) );
360 return;
361 }
362 }
363
364 if ( ! in_array( $id, $current_order, true ) || isset( $deleted_set[ $id ] ) ) {
365 $results->add_error( $index, 'delete', 'class_not_found', __( 'Global class not found', 'elementor' ) );
366 return;
367 }
368
369 $existing = $this->get_repository()->get( $id );
370 if ( ! $existing ) {
371 $results->add_error( $index, 'delete', 'class_not_found', __( 'Global class not found', 'elementor' ) );
372 return;
373 }
374
375 $label = $all_labels[ $id ] ?? ( $existing['label'] ?? '' );
376 $deleted_set[ $id ] = true;
377
378 $intents['deletes'][ $index ] = [
379 'id' => $id,
380 'label' => $label,
381 ];
382 $intents['deleted_ids'][] = $id;
383
384 $results->add_success( $index, 'delete', [
385 'id' => $id,
386 'label' => $label,
387 ] );
388 }
389
390 private function build_touched_items( array $intents, array $all_labels, Bulk_Operations_Result $results ): array {
391 $touched_items = [];
392
393 foreach ( $intents['creates'] as $index => $intent ) {
394 $variants = Style_Variants_Merger::build_variants( $intent['breakpoint_blocks'], $this->get_css_converter() );
395 $touched_items[ $intent['id'] ] = [
396 'id' => $intent['id'],
397 'label' => $intent['label'],
398 'type' => self::CLASS_TYPE,
399 'variants' => $variants,
400 ];
401 }
402
403 foreach ( $intents['updates'] as $index => $intent ) {
404 if ( isset( $intent['breakpoint_blocks'] ) ) {
405 $new_variants = Style_Variants_Merger::build_variants( $intent['breakpoint_blocks'], $this->get_css_converter() );
406 $removal_breakpoints = $intent['removal_breakpoints'];
407 $existing_after_removal = array_values(
408 array_filter(
409 $intent['existing_variants'],
410 fn( $v ) => ! in_array( $v['meta']['breakpoint'] ?? null, $removal_breakpoints, true )
411 )
412 );
413 $merged_variants = Style_Variants_Merger::apply_mode(
414 $existing_after_removal,
415 $new_variants,
416 $intent['mode'],
417 array_column( $intent['breakpoint_blocks'], 'breakpoint' )
418 );
419 $touched_items[ $intent['id'] ] = [
420 'id' => $intent['id'],
421 'label' => $intent['label'],
422 'type' => self::CLASS_TYPE,
423 'variants' => $merged_variants,
424 ];
425 continue;
426 }
427
428 // Label-only update — preserve existing variants, only apply new label.
429 $touched_items[ $intent['id'] ] = [
430 'id' => $intent['id'],
431 'label' => $intent['label'],
432 'type' => self::CLASS_TYPE,
433 'variants' => $intent['existing_variants'],
434 ];
435 }
436
437 return $touched_items;
438 }
439
440 private function handle_duplicated_labels( array $intents, array &$touched_items, array $all_labels, Bulk_Operations_Result $results ): void {
441 $deleted_ids = $intents['deleted_ids'];
442 $existing_labels = [];
443
444 foreach ( $all_labels as $id => $label ) {
445 if ( ! in_array( $id, $deleted_ids, true ) ) {
446 $existing_labels[] = $label;
447 }
448 }
449
450 $pending_labels = [];
451
452 foreach ( $intents['creates'] as $index => $intent ) {
453 if ( ! isset( $touched_items[ $intent['id'] ] ) ) {
454 continue;
455 }
456
457 $label = $intent['label'];
458 $is_duplicate = in_array( $label, $existing_labels, true ) || in_array( $label, $pending_labels, true );
459
460 if ( $is_duplicate ) {
461 $all_existing = array_merge( $existing_labels, $pending_labels );
462 $new_label = Global_Classes_Labels::generate_unique_label( $label, $all_existing );
463
464 $touched_items[ $intent['id'] ]['label'] = $new_label;
465 $pending_labels[] = $new_label;
466
467 $results->add_success( $index, 'create', [
468 'id' => $intent['id'],
469 'label' => $new_label,
470 'modified_label' => [
471 'original' => $label,
472 'modified' => $new_label,
473 ],
474 ] );
475 } else {
476 $pending_labels[] = $label;
477 $results->add_success( $index, 'create', [
478 'id' => $intent['id'],
479 'label' => $label,
480 ] );
481 }
482 }
483
484 foreach ( $intents['updates'] as $index => $intent ) {
485 if ( ! isset( $touched_items[ $intent['id'] ] ) ) {
486 continue;
487 }
488
489 $label = $intent['label'];
490 $current_label = $all_labels[ $intent['id'] ] ?? '';
491
492 $is_duplicate = false;
493 if ( $label !== $current_label ) {
494 $is_duplicate = in_array( $label, $existing_labels, true ) || in_array( $label, $pending_labels, true );
495 }
496
497 if ( $is_duplicate ) {
498 $all_existing = array_merge( $existing_labels, $pending_labels );
499 $new_label = Global_Classes_Labels::generate_unique_label( $label, $all_existing );
500
501 $touched_items[ $intent['id'] ]['label'] = $new_label;
502 $pending_labels[] = $new_label;
503
504 $results->add_success( $index, 'update', [
505 'id' => $intent['id'],
506 'label' => $new_label,
507 'modified_label' => [
508 'original' => $label,
509 'modified' => $new_label,
510 ],
511 ] );
512 } else {
513 if ( $label !== $current_label ) {
514 $pending_labels[] = $label;
515 }
516 $results->add_success( $index, 'update', [
517 'id' => $intent['id'],
518 'label' => $label,
519 ] );
520 }
521 }
522 }
523
524 private function enforce_max_items( array $intents, array &$touched_items, array $all_labels, Bulk_Operations_Result $results ): void {
525 $deleted_count = count( $intents['deleted_ids'] );
526 $existing_count = count( $all_labels ) - $deleted_count;
527 $added_count = count( array_intersect( $intents['added_ids'], array_keys( $touched_items ) ) );
528
529 $total_count = $existing_count + $added_count;
530
531 if ( $total_count <= Global_Classes_REST_API::MAX_ITEMS ) {
532 return;
533 }
534
535 $overflow = $total_count - Global_Classes_REST_API::MAX_ITEMS;
536 $error_message = sprintf(
537 /* translators: %d: Maximum allowed items. */
538 __( 'Global classes limit exceeded. Maximum allowed: %d', 'elementor' ),
539 Global_Classes_REST_API::MAX_ITEMS
540 );
541
542 $creates_to_reject = array_slice( $intents['creates'], -$overflow, $overflow, true );
543
544 foreach ( $creates_to_reject as $index => $intent ) {
545 if ( isset( $touched_items[ $intent['id'] ] ) ) {
546 unset( $touched_items[ $intent['id'] ] );
547
548 $results->add_error( $index, 'create', 'global_classes_limit_exceeded', $error_message );
549 }
550 }
551 }
552
553 private function compute_new_order( array $current_order, array $added_ids, array $deleted_ids ): array {
554 $new_order = array_filter( $current_order, fn( $id ) => ! in_array( $id, $deleted_ids, true ) );
555 $new_order = array_values( $new_order );
556
557 return array_merge( $new_order, $added_ids );
558 }
559
560 private function resolve_class_id_by_label( string $label, array $all_labels ): ?string {
561 $id = array_search( $label, $all_labels, true );
562 return false !== $id ? $id : null;
563 }
564
565 private function bad_request( string $message ): \WP_Error {
566 return new \WP_Error( 'invalid_input', $message, [ 'status' => \WP_Http::BAD_REQUEST ] );
567 }
568
569 private function clear_cache(): void {
570 if ( ! class_exists( Plugin::class ) || ! isset( Plugin::$instance ) ) {
571 return;
572 }
573
574 Plugin::$instance->files_manager->clear_cache();
575 }
576
577 private function get_repository(): Global_Classes_Repository {
578 if ( $this->repository ) {
579 return $this->repository;
580 }
581
582 $kit = Plugin::$instance->kits_manager->get_active_kit();
583
584 return Global_Classes_Repository::make( $kit );
585 }
586
587 private function get_css_converter(): Css_Converter {
588 if ( $this->css_converter ) {
589 return $this->css_converter;
590 }
591
592 $variables_service = $this->create_variables_service();
593
594 $variable_transformer = $variables_service
595 ? new Variable_Prop_Value_Transformer( $variables_service )
596 : null;
597
598 return new Css_Converter(
599 Converter_Registry_Factory::create( $variables_service ),
600 new Null_Failure_Reporter(),
601 Expander_Registry_Factory::create( $variables_service ),
602 $variable_transformer
603 );
604 }
605
606 private function create_variables_service(): ?Variables_Service {
607 if ( ! $this->is_variables_active() ) {
608 return null;
609 }
610
611 $kit = Plugin::$instance->kits_manager->get_active_kit();
612
613 if ( ! $kit ) {
614 return null;
615 }
616
617 return new Variables_Service(
618 new Variables_Repository( $kit ),
619 new Batch_Processor()
620 );
621 }
622
623 private function is_variables_active(): bool {
624 $experiments = Plugin::$instance->experiments;
625
626 return $experiments->is_feature_active( Variables_Module::EXPERIMENT_NAME )
627 && $experiments->is_feature_active( AtomicWidgetsModule::EXPERIMENT_NAME );
628 }
629
630 private function emit_class_created_events( Bulk_Operations_Result $results ): void {
631 foreach ( $results->to_array()['results'] as $row ) {
632 if ( 'ok' !== ( $row['status'] ?? '' ) || 'create' !== ( $row['action'] ?? '' ) ) {
633 continue;
634 }
635
636 Mcp_Event_Dispatcher::emit( 'class_created', [
637 'id' => $row['id'] ?? '',
638 'name' => $row['label'] ?? '',
639 ] );
640 }
641 }
642
643 private function emit_mcp_manage_classes_executed(
644 int $started_at,
645 array $operations,
646 ?\WP_Error $top_level_error = null,
647 array $response = []
648 ): void {
649 $duration_ms = Tool_Performance_Metrics::duration_ms_since( $started_at );
650
651 [ 'status' => $status, 'error_code' => $error_code ] = Tool_Performance_Metrics::resolve_status( $response, $top_level_error );
652
653 $failed_results = array_filter( $response['results'] ?? [], fn( $r ) => 'error' === ( $r['status'] ?? '' ) );
654 $failed_count = count( $failed_results );
655 $failed_codes = array_values( array_unique( array_column( $failed_results, 'code' ) ) );
656
657 $ops_count = count( $operations );
658 $by_action = [];
659 $mode_used = null;
660 $all_css = '';
661
662 foreach ( $operations as $op ) {
663 $action = $op['action'] ?? '';
664 if ( is_string( $action ) && '' !== $action ) {
665 $by_action[ $action ] = ( $by_action[ $action ] ?? 0 ) + 1;
666 }
667
668 if ( 'update' === $action ) {
669 $mode_used = $mode_used ?? ( $op['mode'] ?? 'patch' );
670 }
671
672 if ( isset( $op['css'] ) && is_string( $op['css'] ) ) {
673 $all_css .= ' ' . $op['css'];
674 }
675 }
676
677 $dominant_action = '';
678 if ( ! empty( $by_action ) ) {
679 arsort( $by_action );
680 $dominant_action = (string) array_key_first( $by_action );
681 }
682
683 $vars_count = 0;
684 $var_labels = [];
685 if ( '' !== trim( $all_css ) ) {
686 preg_match_all( '/var\(--([a-zA-Z0-9_-]+)\)/', $all_css, $matches );
687 $var_labels = array_values( array_unique( $matches[1] ?? [] ) );
688 $vars_count = count( $matches[0] ?? [] );
689 }
690
691 $payload = [
692 'tool_name' => $this->get_ability_id(),
693 'status' => $status,
694 'duration_ms' => $duration_ms,
695 'action' => $dominant_action,
696 'operations_count' => $ops_count,
697 'operations_by_type' => $by_action,
698 'failed_operations_count' => $failed_count,
699 'failed_operation_codes' => $failed_codes,
700 'variables_referenced_count' => $vars_count,
701 'variable_labels' => $var_labels,
702 'warning_count' => 0,
703 'warning_types' => [],
704 ];
705
706 if ( null !== $mode_used ) {
707 $payload['mode_used'] = $mode_used;
708 }
709
710 if ( null !== $error_code ) {
711 $payload['error_code'] = $error_code;
712 }
713
714 Mcp_Event_Dispatcher::emit( 'mcp_manage_classes_executed', $payload );
715 }
716 }
717