| 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\DefaultStyles\Default_Styles_Repository; |
| 12 |
use Elementor\Modules\Mcp\Abilities\Utils\Bulk_Operations_Result; |
| 13 |
use Elementor\Modules\Mcp\Abilities\Utils\Style_Variants_Merger; |
| 14 |
use Elementor\Modules\Variables\Module as Variables_Module; |
| 15 |
use Elementor\Modules\Variables\Services\Batch_Operations\Batch_Processor; |
| 16 |
use Elementor\Modules\Variables\Services\Variables_Service; |
| 17 |
use Elementor\Modules\Variables\Storage\Variables_Repository; |
| 18 |
use Elementor\Plugin; |
| 19 |
|
| 20 |
if ( ! defined( 'ABSPATH' ) ) { |
| 21 |
exit; |
| 22 |
} |
| 23 |
|
| 24 |
class Manage_Default_Styles_Ability extends Abstract_Ability { |
| 25 |
|
| 26 |
const CLASS_TYPE = 'class'; |
| 27 |
const MAX_BATCH_SIZE = 20; |
| 28 |
|
| 29 |
private ?Default_Styles_Repository $repository; |
| 30 |
private ?Css_Converter $css_converter; |
| 31 |
|
| 32 |
public function __construct( ?Default_Styles_Repository $repository = null, ?Css_Converter $css_converter = null ) { |
| 33 |
$this->repository = $repository; |
| 34 |
$this->css_converter = $css_converter; |
| 35 |
} |
| 36 |
|
| 37 |
protected function get_ability_id(): string { |
| 38 |
return 'elementor/manage-default-styles'; |
| 39 |
} |
| 40 |
|
| 41 |
protected function get_definition(): Ability_Definition { |
| 42 |
return new Ability_Definition( |
| 43 |
__( 'Manage Default Styles (Site-Wide)', 'elementor' ), |
| 44 |
__( 'Bulk manage the active kit\'s site-wide default styles, keyed by HTML wrapper tag (h1..h6, p, a, section, div, ...). These styles apply to every V4 atomic element that renders that tag on the whole site, sitting on top of each widget\'s built-in base_styles and beneath any inline or global class overrides. Use action=update to upsert (patch or replace) a tag\'s variants via a raw CSS string (supports @media(--breakpoint) + &:hover/&:focus/&:active), and action=delete to remove a tag\'s default style entirely.', 'elementor' ), |
| 45 |
'elementor', |
| 46 |
[ |
| 47 |
'type' => 'object', |
| 48 |
'required' => [ 'status', 'results' ], |
| 49 |
'properties' => [ |
| 50 |
'status' => [ 'type' => 'string' ], |
| 51 |
'results' => [ 'type' => 'array' ], |
| 52 |
], |
| 53 |
], |
| 54 |
[ |
| 55 |
'annotations' => [ |
| 56 |
'readonly' => false, |
| 57 |
'idempotent' => false, |
| 58 |
'destructive' => true, |
| 59 |
], |
| 60 |
], |
| 61 |
fn() => current_user_can( 'manage_options' ), |
| 62 |
[ |
| 63 |
'type' => 'object', |
| 64 |
'required' => [ 'operations' ], |
| 65 |
'properties' => [ |
| 66 |
'operations' => [ |
| 67 |
'type' => 'array', |
| 68 |
'description' => 'Bulk operations (1–20). Each item requires action and tag. update needs css (raw CSS string, same format as manage-classes) and applies site-wide to that HTML tag. Use mode to control merge behaviour on update (patch = upsert variants, replace = overwrite variants for the affected breakpoints). delete removes the tag\'s default style entirely.', |
| 69 |
'items' => [ |
| 70 |
'type' => 'object', |
| 71 |
'required' => [ 'action', 'tag' ], |
| 72 |
'properties' => [ |
| 73 |
'action' => [ |
| 74 |
'type' => 'string', |
| 75 |
'enum' => [ 'update', 'delete' ], |
| 76 |
], |
| 77 |
'tag' => [ |
| 78 |
'type' => 'string', |
| 79 |
'description' => 'HTML wrapper tag to target (e.g. h1, h2, p, a). Must be one of Elementor\'s allowed wrapper tags.', |
| 80 |
], |
| 81 |
'css' => [ |
| 82 |
'type' => 'string', |
| 83 |
'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.', |
| 84 |
], |
| 85 |
'mode' => [ |
| 86 |
'type' => 'string', |
| 87 |
'enum' => [ 'patch', 'replace' ], |
| 88 |
'default' => 'patch', |
| 89 |
'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.', |
| 90 |
], |
| 91 |
], |
| 92 |
], |
| 93 |
], |
| 94 |
], |
| 95 |
] |
| 96 |
); |
| 97 |
} |
| 98 |
|
| 99 |
public function execute( $input = [] ) { |
| 100 |
$input = is_array( $input ) ? $input : []; |
| 101 |
$operations = $input['operations'] ?? null; |
| 102 |
|
| 103 |
if ( ! is_array( $operations ) ) { |
| 104 |
return $this->bad_request( __( 'operations array is required.', 'elementor' ) ); |
| 105 |
} |
| 106 |
|
| 107 |
if ( empty( $operations ) ) { |
| 108 |
return $this->bad_request( __( 'operations must not be empty.', 'elementor' ) ); |
| 109 |
} |
| 110 |
|
| 111 |
if ( count( $operations ) > self::MAX_BATCH_SIZE ) { |
| 112 |
return new \WP_Error( |
| 113 |
'batch_size_exceeded', |
| 114 |
sprintf( |
| 115 |
/* translators: %d: maximum operations per request */ |
| 116 |
__( 'Maximum %d operations per request.', 'elementor' ), |
| 117 |
self::MAX_BATCH_SIZE |
| 118 |
), |
| 119 |
[ |
| 120 |
'status' => \WP_Http::BAD_REQUEST, |
| 121 |
'max_allowed' => self::MAX_BATCH_SIZE, |
| 122 |
] |
| 123 |
); |
| 124 |
} |
| 125 |
|
| 126 |
return $this->handle_bulk( $operations ); |
| 127 |
} |
| 128 |
|
| 129 |
private function handle_bulk( array $operations ): array { |
| 130 |
$results = new Bulk_Operations_Result(); |
| 131 |
|
| 132 |
foreach ( $operations as $index => $operation ) { |
| 133 |
$this->process_operation( (int) $index, $operation, $results ); |
| 134 |
} |
| 135 |
|
| 136 |
return $results->to_array(); |
| 137 |
} |
| 138 |
|
| 139 |
private function process_operation( int $index, $operation, Bulk_Operations_Result $results ): void { |
| 140 |
if ( ! is_array( $operation ) ) { |
| 141 |
$results->add_error( $index, '', 'invalid_input', __( 'Invalid operation.', 'elementor' ) ); |
| 142 |
return; |
| 143 |
} |
| 144 |
|
| 145 |
$action = $operation['action'] ?? ''; |
| 146 |
$tag = isset( $operation['tag'] ) ? (string) $operation['tag'] : ''; |
| 147 |
|
| 148 |
if ( '' === $tag ) { |
| 149 |
$results->add_error( $index, (string) $action, 'invalid_input', __( 'tag is required.', 'elementor' ) ); |
| 150 |
return; |
| 151 |
} |
| 152 |
|
| 153 |
if ( ! Default_Styles_Repository::is_allowed_tag( $tag ) ) { |
| 154 |
$results->add_error( $index, (string) $action, 'invalid_tag', sprintf( |
| 155 |
/* translators: %s: HTML tag */ |
| 156 |
__( 'Invalid HTML tag: %s.', 'elementor' ), |
| 157 |
$tag |
| 158 |
) ); |
| 159 |
return; |
| 160 |
} |
| 161 |
|
| 162 |
switch ( $action ) { |
| 163 |
case 'update': |
| 164 |
$this->handle_update( $index, $tag, $operation, $results ); |
| 165 |
return; |
| 166 |
|
| 167 |
case 'delete': |
| 168 |
$this->handle_delete( $index, $tag, $results ); |
| 169 |
return; |
| 170 |
|
| 171 |
default: |
| 172 |
$results->add_error( $index, (string) $action, 'invalid_input', sprintf( |
| 173 |
/* translators: %s: action name */ |
| 174 |
__( 'Unknown action: %s.', 'elementor' ), |
| 175 |
$action |
| 176 |
) ); |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
private function handle_update( int $index, string $tag, array $operation, Bulk_Operations_Result $results ): void { |
| 181 |
$css_string = $operation['css'] ?? null; |
| 182 |
|
| 183 |
if ( ! is_string( $css_string ) || '' === trim( $css_string ) ) { |
| 184 |
$results->add_error( $index, 'update', 'invalid_input', __( 'update requires css.', 'elementor' ) ); |
| 185 |
return; |
| 186 |
} |
| 187 |
|
| 188 |
$mode = $operation['mode'] ?? 'patch'; |
| 189 |
if ( ! in_array( $mode, [ 'patch', 'replace' ], true ) ) { |
| 190 |
$results->add_error( $index, 'update', 'invalid_input', sprintf( 'Unknown mode: %s. Valid modes: patch, replace.', $mode ) ); |
| 191 |
return; |
| 192 |
} |
| 193 |
|
| 194 |
$parsed = Style_Variants_Merger::parse_css_string( |
| 195 |
$css_string, |
| 196 |
$this->get_active_breakpoint_keys(), |
| 197 |
$index, |
| 198 |
'update', |
| 199 |
$results, |
| 200 |
fn() => $this->get_css_converter() |
| 201 |
); |
| 202 |
|
| 203 |
if ( null === $parsed ) { |
| 204 |
return; |
| 205 |
} |
| 206 |
|
| 207 |
$new_variants = Style_Variants_Merger::build_variants( $parsed['breakpoint_blocks'], $this->get_css_converter() ); |
| 208 |
|
| 209 |
$existing = $this->get_repository()->get( $tag ); |
| 210 |
$existing_variants = is_array( $existing['variants'] ?? null ) ? $existing['variants'] : []; |
| 211 |
|
| 212 |
$existing_after_removal = array_values( |
| 213 |
array_filter( |
| 214 |
$existing_variants, |
| 215 |
fn( $v ) => ! in_array( $v['meta']['breakpoint'] ?? null, $parsed['removal_breakpoints'], true ) |
| 216 |
) |
| 217 |
); |
| 218 |
|
| 219 |
$merged = Style_Variants_Merger::apply_mode( |
| 220 |
$existing_after_removal, |
| 221 |
$new_variants, |
| 222 |
$mode, |
| 223 |
array_column( $parsed['breakpoint_blocks'], 'breakpoint' ) |
| 224 |
); |
| 225 |
|
| 226 |
$merged = $this->strip_null_props( $merged ); |
| 227 |
|
| 228 |
$persisted = $this->get_repository()->put( $tag, [ |
| 229 |
'type' => self::CLASS_TYPE, |
| 230 |
'variants' => $merged, |
| 231 |
] ); |
| 232 |
|
| 233 |
if ( ! $persisted ) { |
| 234 |
$results->add_error( $index, 'update', 'persist_failed', __( 'Failed to save default style.', 'elementor' ) ); |
| 235 |
return; |
| 236 |
} |
| 237 |
|
| 238 |
$results->add_success( $index, 'update', [ 'tag' => $tag ] ); |
| 239 |
} |
| 240 |
|
| 241 |
private function handle_delete( int $index, string $tag, Bulk_Operations_Result $results ): void { |
| 242 |
$existing = $this->get_repository()->get( $tag ); |
| 243 |
|
| 244 |
if ( null === $existing ) { |
| 245 |
$results->add_error( $index, 'delete', 'not_found', __( 'Default style not found for this tag.', 'elementor' ) ); |
| 246 |
return; |
| 247 |
} |
| 248 |
|
| 249 |
$this->get_repository()->delete( $tag ); |
| 250 |
|
| 251 |
$results->add_success( $index, 'delete', [ 'tag' => $tag ] ); |
| 252 |
} |
| 253 |
|
| 254 |
private function strip_null_props( array $variants ): array { |
| 255 |
foreach ( $variants as &$variant ) { |
| 256 |
unset( $variant['null_props'] ); |
| 257 |
} |
| 258 |
|
| 259 |
return $variants; |
| 260 |
} |
| 261 |
|
| 262 |
private function bad_request( string $message ): \WP_Error { |
| 263 |
return new \WP_Error( 'invalid_input', $message, [ 'status' => \WP_Http::BAD_REQUEST ] ); |
| 264 |
} |
| 265 |
|
| 266 |
protected function get_active_breakpoint_keys(): array { |
| 267 |
return Plugin::$instance->breakpoints->get_active_devices_list(); |
| 268 |
} |
| 269 |
|
| 270 |
private function get_repository(): Default_Styles_Repository { |
| 271 |
if ( $this->repository ) { |
| 272 |
return $this->repository; |
| 273 |
} |
| 274 |
|
| 275 |
$kit = Plugin::$instance->kits_manager->get_active_kit(); |
| 276 |
|
| 277 |
return Default_Styles_Repository::make( $kit ); |
| 278 |
} |
| 279 |
|
| 280 |
private function get_css_converter(): Css_Converter { |
| 281 |
if ( $this->css_converter ) { |
| 282 |
return $this->css_converter; |
| 283 |
} |
| 284 |
|
| 285 |
$variables_service = $this->create_variables_service(); |
| 286 |
|
| 287 |
$variable_transformer = $variables_service |
| 288 |
? new Variable_Prop_Value_Transformer( $variables_service ) |
| 289 |
: null; |
| 290 |
|
| 291 |
$this->css_converter = new Css_Converter( |
| 292 |
Converter_Registry_Factory::create( $variables_service ), |
| 293 |
new Null_Failure_Reporter(), |
| 294 |
Expander_Registry_Factory::create( $variables_service ), |
| 295 |
$variable_transformer |
| 296 |
); |
| 297 |
|
| 298 |
return $this->css_converter; |
| 299 |
} |
| 300 |
|
| 301 |
private function create_variables_service(): ?Variables_Service { |
| 302 |
if ( ! $this->is_variables_active() ) { |
| 303 |
return null; |
| 304 |
} |
| 305 |
|
| 306 |
$kit = Plugin::$instance->kits_manager->get_active_kit(); |
| 307 |
|
| 308 |
if ( ! $kit ) { |
| 309 |
return null; |
| 310 |
} |
| 311 |
|
| 312 |
return new Variables_Service( |
| 313 |
new Variables_Repository( $kit ), |
| 314 |
new Batch_Processor() |
| 315 |
); |
| 316 |
} |
| 317 |
|
| 318 |
private function is_variables_active(): bool { |
| 319 |
$experiments = Plugin::$instance->experiments; |
| 320 |
|
| 321 |
return $experiments->is_feature_active( Variables_Module::EXPERIMENT_NAME ) |
| 322 |
&& $experiments->is_feature_active( AtomicWidgetsModule::EXPERIMENT_NAME ); |
| 323 |
} |
| 324 |
} |
| 325 |
|