PluginProbe
Elementor Website Builder – more than just a page builder / 4.1.0-beta1
Elementor Website Builder – more than just a page builder v4.1.0-beta1
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 / global-classes / global-classes-rest-api.php

global-classes-rest-api.php in Elementor Website Builder – more than just a page builder 4.1.0-beta1, at modules/global-classes/global-classes-rest-api.php

518 lines 14.6 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\GlobalClasses;
4
5 use Elementor\Core\Kits\Documents\Kit;
6 use Elementor\Core\Utils\Api\Error_Builder;
7 use Elementor\Core\Utils\Api\Response_Builder;
8 use Elementor\Modules\GlobalClasses\Database\Migrations\Add_Capabilities;
9 use Elementor\Modules\GlobalClasses\Usage\Applied_Global_Classes_Usage;
10 use Elementor\Plugin;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit; // Exit if accessed directly.
14 }
15
16 class Global_Classes_REST_API {
17 const API_NAMESPACE = 'elementor/v1';
18 const API_BASE = 'global-classes';
19 const API_BASE_USAGE = self::API_BASE . '/usage';
20 const API_BASE_POST = self::API_BASE . '/post';
21 const API_BASE_STYLES = self::API_BASE . '/styles';
22 const MAX_ITEMS = 1000;
23 const LABEL_PREFIX = 'DUP_';
24 const MAX_LABEL_LENGTH = 50;
25 private ?Global_Classes_Repository $repository = null;
26 private ?Global_Classes_Relations $relations = null;
27 private ?Kit $kit = null;
28
29 public function register_hooks() {
30 add_action( 'rest_api_init', fn() => $this->register_routes() );
31 }
32
33 public function invalidate_cache() {
34 $this->kit = null;
35 $this->repository = null;
36 $this->relations = null;
37 }
38
39 private function get_kit(): ?Kit {
40 if ( ! $this->kit ) {
41 $this->kit = Plugin::$instance->kits_manager->get_active_kit();
42 }
43
44 return $this->kit;
45 }
46
47 private function get_repository() {
48 if ( ! $this->repository ) {
49 $this->repository = new Global_Classes_Repository( $this->get_kit() );
50 }
51
52 return $this->repository;
53 }
54
55 private function get_classes_relations(): Global_Classes_Relations {
56 if ( ! $this->relations ) {
57 $this->relations = new Global_Classes_Relations();
58 }
59
60 return $this->relations;
61 }
62
63 private function register_routes() {
64 // cache invalidation at this point is solely for tests, in particular - Test_Global_Classes_Rest_Api
65 $this->invalidate_cache();
66
67 register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE, [
68 [
69 'methods' => 'GET',
70 'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->all( $request ) ),
71 'permission_callback' => fn() => is_user_logged_in(),
72 'args' => [
73 'context' => [
74 'type' => 'string',
75 'required' => false,
76 'default' => Global_Classes_Repository::CONTEXT_FRONTEND,
77 'enum' => [
78 Global_Classes_Repository::CONTEXT_FRONTEND,
79 Global_Classes_Repository::CONTEXT_PREVIEW,
80 ],
81 ],
82 ],
83 ],
84 ] );
85
86 register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE_POST, [
87 [
88 'methods' => 'GET',
89 'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->styles_for_post( $request ) ),
90 'permission_callback' => fn() => is_user_logged_in(),
91 'args' => [
92 'context' => [
93 'type' => 'string',
94 'required' => false,
95 'default' => Global_Classes_Repository::CONTEXT_FRONTEND,
96 'enum' => [
97 Global_Classes_Repository::CONTEXT_FRONTEND,
98 Global_Classes_Repository::CONTEXT_PREVIEW,
99 ],
100 ],
101 'post_id' => [
102 'type' => 'integer',
103 'required' => true,
104 ],
105 ],
106 ],
107 ] );
108
109 register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE_STYLES, [
110 [
111 'methods' => 'GET',
112 'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->styles_by_ids( $request ) ),
113 'permission_callback' => fn() => is_user_logged_in(),
114 'args' => [
115 'context' => [
116 'type' => 'string',
117 'required' => false,
118 'default' => Global_Classes_Repository::CONTEXT_FRONTEND,
119 'enum' => [
120 Global_Classes_Repository::CONTEXT_FRONTEND,
121 Global_Classes_Repository::CONTEXT_PREVIEW,
122 ],
123 ],
124 'ids' => [
125 'type' => 'string',
126 'required' => true,
127 'description' => 'Comma-separated list of global class IDs',
128 ],
129 ],
130 ],
131 ] );
132
133 register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE_USAGE, [
134 [
135 'methods' => 'GET',
136 'callback' => fn() => $this->route_wrapper( fn() => $this->get_usage() ),
137 'permission_callback' => fn() => current_user_can( 'manage_options' ),
138 'args' => [
139 'context' => [
140 'type' => 'string',
141 'required' => false,
142 'default' => Global_Classes_Repository::CONTEXT_FRONTEND,
143 'enum' => [
144 Global_Classes_Repository::CONTEXT_FRONTEND,
145 Global_Classes_Repository::CONTEXT_PREVIEW,
146 ],
147 ],
148 ],
149 ],
150 ] );
151
152 register_rest_route( self::API_NAMESPACE, '/' . self::API_BASE, [
153 [
154 'methods' => 'PUT',
155 'callback' => fn( $request ) => $this->route_wrapper( fn() => $this->put( $request ) ),
156 'permission_callback' => fn() => current_user_can( Add_Capabilities::UPDATE_CLASS ),
157 'args' => [
158 'context' => [
159 'type' => 'string',
160 'required' => false,
161 'default' => Global_Classes_Repository::CONTEXT_FRONTEND,
162 'enum' => [
163 Global_Classes_Repository::CONTEXT_FRONTEND,
164 Global_Classes_Repository::CONTEXT_PREVIEW,
165 ],
166 ],
167 'changes' => [
168 'type' => 'object',
169 'required' => true,
170 'additionalProperties' => false,
171 'properties' => [
172 'added' => [
173 'type' => 'array',
174 'required' => true,
175 'items' => [ 'type' => 'string' ],
176 ],
177 'deleted' => [
178 'type' => 'array',
179 'required' => true,
180 'items' => [ 'type' => 'string' ],
181 ],
182 'modified' => [
183 'type' => 'array',
184 'required' => true,
185 'items' => [ 'type' => 'string' ],
186 ],
187 'order' => [
188 'type' => 'boolean',
189 'required' => false,
190 ],
191 ],
192 ],
193 'items' => [
194 'required' => true,
195 'type' => 'object',
196 'additionalProperties' => [
197 'type' => 'object',
198 'properties' => [
199 'id' => [
200 'type' => 'string',
201 'required' => true,
202 ],
203 'variants' => [
204 'type' => 'array',
205 'required' => true,
206 ],
207 'type' => [
208 'type' => 'string',
209 'enum' => [ 'class' ],
210 'required' => true,
211 ],
212 'label' => [
213 'type' => 'string',
214 'required' => true,
215 ],
216 ],
217 ],
218 ],
219 'order' => [
220 'required' => true,
221 'type' => 'array',
222 'items' => [
223 'type' => 'string',
224 ],
225 ],
226 ],
227 ],
228 ] );
229 }
230
231 private function all( \WP_REST_Request $request ) {
232 $context = $request->get_param( 'context' );
233 $is_preview = Global_Classes_Repository::CONTEXT_PREVIEW === $context;
234 $label_by_id = $this->get_repository()->set_preview( $is_preview )->all_labels();
235 $list = [];
236
237 foreach ( $label_by_id as $id => $label ) {
238 $list[] = [
239 'id' => $id,
240 'label' => $label,
241 ];
242 }
243
244 return Response_Builder::make( $list )->build();
245 }
246
247 private function styles_for_post( \WP_REST_Request $request ) {
248 $context = $request->get_param( 'context' );
249 $is_preview = Global_Classes_Repository::CONTEXT_PREVIEW === $context;
250 $post_id = (int) $request->get_param( 'post_id' );
251
252 $document_class_ids = $this->get_classes_relations()->set_preview( $is_preview )->get_styles_by_post( $post_id );
253
254 if ( empty( $document_class_ids ) ) {
255 return Response_Builder::make( (object) [] )->set_meta( [ 'order' => [] ] )->build();
256 }
257
258 $repository = $this->get_repository()->set_preview( $is_preview );
259 $global_order = array_keys( $repository->all_labels() );
260 $filtered_order = array_values( array_intersect( $global_order, $document_class_ids ) );
261 $items = $repository->get_by_ids( $document_class_ids );
262
263 $result = [];
264
265 foreach ( $document_class_ids as $id ) {
266 $result[ $id ] = $items[ $id ] ?? null;
267 }
268
269 return Response_Builder::make( (object) $result )
270 ->set_meta( [ 'order' => $filtered_order ] )
271 ->build();
272 }
273
274 private function styles_by_ids( \WP_REST_Request $request ) {
275 $context = $request->get_param( 'context' );
276 $is_preview = Global_Classes_Repository::CONTEXT_PREVIEW === $context;
277 $ids_param = $request->get_param( 'ids' );
278
279 $requested_ids = array_map( 'trim', explode( ',', $ids_param ) );
280 $requested_ids = array_filter( $requested_ids );
281
282 if ( empty( $requested_ids ) ) {
283 return Response_Builder::make( (object) [] )->set_meta( [ 'order' => [] ] )->build();
284 }
285
286 $repository = $this->get_repository()->set_preview( $is_preview );
287 $global_order = array_keys( $repository->all_labels() );
288 $filtered_order = array_values( array_intersect( $global_order, $requested_ids ) );
289 $items = $repository->get_by_ids( $requested_ids );
290
291 $result = [];
292
293 foreach ( $requested_ids as $id ) {
294 $result[ $id ] = $items[ $id ] ?? null;
295 }
296
297 return Response_Builder::make( (object) $result )
298 ->set_meta( [ 'order' => $filtered_order ] )
299 ->build();
300 }
301
302 private function get_usage() {
303 $classes_usage = ( new Applied_Global_Classes_Usage() )->get_detailed_usage();
304
305 return Response_Builder::make( (object) $classes_usage )->build();
306 }
307
308 private function put( \WP_REST_Request $request ) {
309 $context = $request->get_param( 'context' );
310 $is_preview = Global_Classes_Repository::CONTEXT_PREVIEW === $context;
311 $changes = $request->get_param( 'changes' ) ?? [];
312 $added_ids = $changes['added'] ?? [];
313 $deleted_ids = $changes['deleted'] ?? [];
314 $order = $request->get_param( 'order' ) ?? [];
315
316 $repository = $this->get_repository()->set_preview( $is_preview );
317 $all_label_by_id = $repository->all_labels();
318 $existing_label_list = $this->global_classes_existing_label_list( $all_label_by_id, $deleted_ids );
319 $total_count = count( $all_label_by_id ) - count( $deleted_ids ) + count( $added_ids );
320
321 $parser = Global_Classes_Parser::make();
322 $items_result = $parser->parse_items( $request->get_param( 'items' ) ?? [] );
323
324 if ( ! $items_result->is_valid() ) {
325 return Error_Builder::make( 'invalid_items' )
326 ->set_status( 400 )
327 ->set_message( 'Invalid items: ' . $items_result->errors()->to_string() )
328 ->build();
329 }
330
331 $touched_items = $items_result->unwrap();
332
333 if ( $total_count > self::MAX_ITEMS ) {
334 return Error_Builder::make( 'global_classes_limit_exceeded' )
335 ->set_status( 400 )
336 ->set_meta( [
337 'current_count' => $total_count,
338 'max_allowed' => self::MAX_ITEMS,
339 ] )
340 ->set_message( sprintf(
341 /* translators: %d: Maximum allowed items. */
342 __( 'Global classes limit exceeded. Maximum allowed: %d', 'elementor' ),
343 self::MAX_ITEMS
344 ) )
345 ->build();
346 }
347
348 $duplicated_labels = Global_Classes_Parser::check_for_duplicate_labels(
349 $all_label_by_id,
350 $deleted_ids,
351 $touched_items,
352 $added_ids
353 );
354
355 $duplicate_validation_result = null;
356
357 if ( ! empty( $duplicated_labels ) ) {
358 $modified_labels = $this->handle_duplicates( $duplicated_labels, $existing_label_list );
359 $duplicate_validation_result = $modified_labels;
360
361 foreach ( $modified_labels as $item_id => $labels ) {
362 $touched_items[ $item_id ]['label'] = $labels['modified'];
363 }
364 }
365
366 $final_item_ids = array_keys( $this->merge_touched_with_existing_labels( $all_label_by_id, $touched_items, $deleted_ids ) );
367
368 $order_result = $parser->parse_order( $order, $final_item_ids );
369
370 if ( ! $order_result->is_valid() ) {
371 return Error_Builder::make( 'invalid_order' )
372 ->set_status( 400 )
373 ->set_message( 'Invalid order: ' . $order_result->errors()->to_string() )
374 ->build();
375 }
376
377 $repository->apply_changes( $touched_items, [
378 'added' => $added_ids,
379 'deleted' => $changes['deleted'] ?? [],
380 'modified' => $changes['modified'] ?? [],
381 'order' => isset( $changes['order'] ) && $changes['order'], // boolean indicating if the order has changed
382 ], $order_result->unwrap() );
383
384 if ( $duplicate_validation_result ) {
385 return Response_Builder::make( [
386 'code' => 'DUPLICATED_LABEL',
387 'modifiedLabels' => $duplicate_validation_result,
388 ] )->build();
389 }
390
391 return Response_Builder::make()->no_content()->build();
392 }
393
394 private function global_classes_existing_label_list( array $label_by_id, array $deleted_ids ): array {
395 $labels = [];
396
397 foreach ( $label_by_id as $id => $label ) {
398 if ( in_array( $id, $deleted_ids, true ) ) {
399 continue;
400 }
401
402 $labels[] = $label;
403 }
404
405 return $labels;
406 }
407
408 private function merge_touched_with_existing_labels( array $label_by_id, array $touched_items, array $deleted_ids ): array {
409 $final = [];
410
411 foreach ( $label_by_id as $id => $label ) {
412 if ( in_array( $id, $deleted_ids, true ) ) {
413 continue;
414 }
415
416 if ( isset( $touched_items[ $id ] ) ) {
417 $final[ $id ] = $touched_items[ $id ];
418 } else {
419 $final[ $id ] = [
420 'id' => $id,
421 'label' => $label,
422 'type' => 'class',
423 'variants' => [],
424 ];
425 }
426 }
427
428 foreach ( $touched_items as $id => $item ) {
429 if ( ! isset( $final[ $id ] ) ) {
430 $final[ $id ] = $item;
431 }
432 }
433
434 return $final;
435 }
436
437 private function route_wrapper( callable $cb ) {
438 try {
439 $response = $cb();
440 } catch ( \Exception $e ) {
441 return Error_Builder::make( 'unexpected_error' )
442 ->set_message( __( 'Something went wrong', 'elementor' ) )
443 ->build();
444 }
445
446 return $response;
447 }
448
449 private function handle_duplicates( array $duplicate_labels, array $existing_labels ) {
450
451 $modified_labels = [];
452
453 foreach ( $duplicate_labels as $duplicate_label ) {
454 $item_id = $duplicate_label['item_id'];
455 $original_label = $duplicate_label['label'];
456
457 $modified_label = $this->generate_unique_label( $original_label, $existing_labels );
458
459 $modified_labels[ $item_id ] = [
460 'original' => $original_label,
461 'modified' => $modified_label,
462 ];
463 }
464
465 return $modified_labels;
466 }
467
468
469 private function generate_unique_label( $original_label, $existing_labels ) {
470 $prefix = self::LABEL_PREFIX;
471 $max_length = self::MAX_LABEL_LENGTH;
472
473 $has_prefix = strpos( $original_label, $prefix ) === 0;
474
475 if ( $has_prefix ) {
476 $base_label = substr( $original_label, strlen( $prefix ) );
477
478 $counter = 1;
479 $new_label = $prefix . $base_label . $counter;
480
481 while ( in_array( $new_label, $existing_labels, true ) ) {
482 ++$counter;
483 $new_label = $prefix . $base_label . $counter;
484 }
485
486 if ( strlen( $new_label ) > $max_length ) {
487 $available_length = $max_length - strlen( $prefix . $counter );
488 $base_label = substr( $base_label, 0, $available_length );
489 $new_label = $prefix . $base_label . $counter;
490 }
491 } else {
492 $new_label = $prefix . $original_label;
493
494 if ( strlen( $new_label ) > $max_length ) {
495 $available_length = $max_length - strlen( $prefix );
496 $new_label = $prefix . substr( $original_label, 0, $available_length );
497 }
498
499 $counter = 1;
500 $base_label = substr( $original_label, 0, $available_length ?? strlen( $original_label ) );
501
502 while ( in_array( $new_label, $existing_labels, true ) ) {
503 $new_label = $prefix . $base_label . $counter;
504
505 if ( strlen( $new_label ) > $max_length ) {
506 $available_length = $max_length - strlen( $prefix . $counter );
507 $base_label = substr( $original_label, 0, $available_length );
508 $new_label = $prefix . $base_label . $counter;
509 }
510
511 ++$counter;
512 }
513 }
514
515 return $new_label;
516 }
517 }
518