| 1 |
<?php |
| 2 |
|
| 3 |
namespace Elementor\Modules\GlobalClasses\ImportExportUtils; |
| 4 |
|
| 5 |
use Elementor\App\Modules\ImportExportCustomization\Utils as ImportExportUtils; |
| 6 |
use Elementor\Modules\GlobalClasses\Global_Class_Post; |
| 7 |
use Elementor\Modules\GlobalClasses\Global_Classes_Repository; |
| 8 |
use Elementor\Modules\GlobalClasses\Global_Classes_REST_API; |
| 9 |
use Elementor\Modules\AtomicWidgets\Parsers\Style_Parser; |
| 10 |
use Elementor\Modules\AtomicWidgets\Styles\Style_Schema; |
| 11 |
use Elementor\Modules\AtomicWidgets\PropTypeMigrations\Migrations_Orchestrator; |
| 12 |
use Elementor\Plugin; |
| 13 |
|
| 14 |
if ( ! defined( 'ABSPATH' ) ) { |
| 15 |
exit; |
| 16 |
} |
| 17 |
|
| 18 |
class Import_Utils { |
| 19 |
|
| 20 |
const ORDER_FILE = 'order.json'; |
| 21 |
const DEFAULT_CONFLICT_RESOLUTION = 'skip'; |
| 22 |
|
| 23 |
const ERROR_NOT_ARRAY = 'not_array'; |
| 24 |
const ERROR_MISSING_FIELDS = 'missing_fields'; |
| 25 |
const ERROR_FILE_NOT_FOUND = 'file_not_found'; |
| 26 |
const ERROR_INVALID_JSON = 'invalid_json'; |
| 27 |
const ERROR_INVALID_PROPS = 'invalid_props'; |
| 28 |
const ERROR_ID_MISMATCH = 'id_mismatch'; |
| 29 |
const ERROR_LIMIT_REACHED = 'limit_reached'; |
| 30 |
|
| 31 |
const EMPTY_RESULT = [ |
| 32 |
'created' => [], |
| 33 |
'renamed' => [], |
| 34 |
'replaced' => [], |
| 35 |
'skipped' => [], |
| 36 |
'failed' => [], |
| 37 |
]; |
| 38 |
|
| 39 |
public static function import_classes( string $classes_dir, array $options = [] ) { |
| 40 |
$order_file = rtrim( $classes_dir, '/' ) . '/' . self::ORDER_FILE; |
| 41 |
$active_kit = Plugin::$instance->kits_manager->get_active_kit(); |
| 42 |
|
| 43 |
if ( ! $active_kit || ! is_dir( $classes_dir ) || ! file_exists( $order_file ) ) { |
| 44 |
return self::EMPTY_RESULT; |
| 45 |
} |
| 46 |
|
| 47 |
$conflict_resolution = $options['conflict_resolution'] ?? self::DEFAULT_CONFLICT_RESOLUTION; |
| 48 |
|
| 49 |
$classes_repository = Global_Classes_Repository::make( $active_kit ); |
| 50 |
$classes_repository->set_preview( false ); |
| 51 |
|
| 52 |
$imported_classes_order = json_decode( file_get_contents( $order_file ), true ); |
| 53 |
|
| 54 |
if ( ! is_array( $imported_classes_order ) ) { |
| 55 |
throw new \Exception( 'Invalid file: order.json is not valid JSON.' ); |
| 56 |
} |
| 57 |
|
| 58 |
if ( empty( $imported_classes_order ) ) { |
| 59 |
return self::EMPTY_RESULT; |
| 60 |
} |
| 61 |
|
| 62 |
global $wpdb; |
| 63 |
$wpdb->query( 'START TRANSACTION' ); |
| 64 |
|
| 65 |
try { |
| 66 |
[ 'result' => $result, 'changes' => $changes ] = self::do_import( $classes_repository, $classes_dir, $imported_classes_order, $conflict_resolution ); |
| 67 |
$wpdb->query( 'COMMIT' ); |
| 68 |
} catch ( \Throwable $e ) { |
| 69 |
$wpdb->query( 'ROLLBACK' ); |
| 70 |
throw $e; |
| 71 |
} |
| 72 |
|
| 73 |
if ( $changes ) { |
| 74 |
if ( function_exists( 'wp_cache_flush_runtime' ) ) { |
| 75 |
wp_cache_flush_runtime(); |
| 76 |
} |
| 77 |
do_action( 'elementor/global_classes/update', Global_Classes_Repository::CONTEXT_FRONTEND, $changes ); |
| 78 |
} |
| 79 |
|
| 80 |
return $result; |
| 81 |
} |
| 82 |
|
| 83 |
private static function do_import( |
| 84 |
Global_Classes_Repository $classes_repository, |
| 85 |
string $classes_dir, |
| 86 |
array $imported_classes_order, |
| 87 |
string $conflict_resolution |
| 88 |
): array { |
| 89 |
$added_classes_order = []; |
| 90 |
$added_classes_labels = []; |
| 91 |
$modified_classes = []; |
| 92 |
$deleted_classes = []; |
| 93 |
|
| 94 |
$previous_order = $classes_repository->get_order(); |
| 95 |
$order_set = array_flip( $previous_order ); |
| 96 |
$style_parser = Style_Parser::make( Style_Schema::get() ); |
| 97 |
$classes_dir = rtrim( $classes_dir, '/' ); |
| 98 |
|
| 99 |
if ( 'override-all' === $conflict_resolution ) { |
| 100 |
$deleted_classes = $previous_order; |
| 101 |
$classes_repository->delete_all(); |
| 102 |
$previous_order = []; |
| 103 |
$order_set = []; |
| 104 |
} |
| 105 |
|
| 106 |
$label_to_id_map = self::build_label_to_id_map_from_labels( $classes_repository->all_labels() ); |
| 107 |
|
| 108 |
$result = self::EMPTY_RESULT; |
| 109 |
|
| 110 |
foreach ( $imported_classes_order as $import_entry ) { |
| 111 |
[ 'is_valid' => $is_valid, 'error' => $validation_error ] = self::validate_class_entry( $import_entry ); |
| 112 |
if ( ! $is_valid ) { |
| 113 |
$result['failed'][] = [ |
| 114 |
'import_entry' => $import_entry, |
| 115 |
'error' => $validation_error, |
| 116 |
]; |
| 117 |
continue; |
| 118 |
} |
| 119 |
|
| 120 |
$action = self::resolve_item_action( $import_entry, $label_to_id_map, $conflict_resolution ); |
| 121 |
|
| 122 |
if ( 'skip' === $action ) { |
| 123 |
$result['skipped'][] = [ 'import_entry' => $import_entry ]; |
| 124 |
continue; |
| 125 |
} |
| 126 |
|
| 127 |
if ( 'replace' !== $action && count( $order_set ) >= Global_Classes_REST_API::MAX_ITEMS ) { |
| 128 |
$result['failed'][] = [ |
| 129 |
'import_entry' => $import_entry, |
| 130 |
'error' => self::ERROR_LIMIT_REACHED, |
| 131 |
]; |
| 132 |
continue; |
| 133 |
} |
| 134 |
|
| 135 |
$class_file = $classes_dir . '/' . $import_entry['id'] . '.json'; |
| 136 |
if ( ! file_exists( $class_file ) ) { |
| 137 |
$result['failed'][] = [ |
| 138 |
'import_entry' => $import_entry, |
| 139 |
'error' => self::ERROR_FILE_NOT_FOUND, |
| 140 |
]; |
| 141 |
continue; |
| 142 |
} |
| 143 |
|
| 144 |
$raw_item = json_decode( file_get_contents( $class_file ), true ); |
| 145 |
if ( ! is_array( $raw_item ) ) { |
| 146 |
$result['failed'][] = [ |
| 147 |
'import_entry' => $import_entry, |
| 148 |
'error' => self::ERROR_INVALID_JSON, |
| 149 |
]; |
| 150 |
continue; |
| 151 |
} |
| 152 |
|
| 153 |
[ 'is_valid' => $is_valid, 'error' => $sanitize_error, 'sanitized' => $sanitized_item ] = self::sanitize_item( $import_entry['id'], $raw_item, $style_parser ); |
| 154 |
if ( ! $is_valid ) { |
| 155 |
$result['failed'][] = [ |
| 156 |
'import_entry' => $import_entry, |
| 157 |
'error' => $sanitize_error, |
| 158 |
]; |
| 159 |
continue; |
| 160 |
} |
| 161 |
|
| 162 |
if ( 'replace' === $action ) { |
| 163 |
$existing_id = $label_to_id_map[ strtolower( $import_entry['label'] ) ]; |
| 164 |
self::replace_existing_class( $existing_id, $sanitized_item ); |
| 165 |
|
| 166 |
$modified_classes[] = $existing_id; |
| 167 |
$result['replaced'][] = [ |
| 168 |
'import_entry' => $import_entry, |
| 169 |
'result_entry' => [ |
| 170 |
'id' => $existing_id, |
| 171 |
'label' => $import_entry['label'], |
| 172 |
], |
| 173 |
]; |
| 174 |
continue; |
| 175 |
} |
| 176 |
|
| 177 |
if ( 'rename' === $action ) { |
| 178 |
$existing_labels = array_keys( $label_to_id_map ); |
| 179 |
|
| 180 |
$new_label = ImportExportUtils::resolve_label_conflict( $import_entry['label'], $existing_labels ); |
| 181 |
$sanitized_item['label'] = $new_label; |
| 182 |
} |
| 183 |
|
| 184 |
$new_id = $sanitized_item['id']; |
| 185 |
|
| 186 |
if ( isset( $order_set[ $new_id ] ) ) { |
| 187 |
$new_id = self::generate_unique_id( $order_set ); |
| 188 |
$sanitized_item['id'] = $new_id; |
| 189 |
} |
| 190 |
|
| 191 |
self::create_new_class( $sanitized_item ); |
| 192 |
$order_set[ $new_id ] = true; |
| 193 |
$added_classes_order[] = $new_id; |
| 194 |
$added_classes_labels[ $new_id ] = $sanitized_item['label']; |
| 195 |
$label_to_id_map[ strtolower( $sanitized_item['label'] ) ] = $new_id; |
| 196 |
|
| 197 |
$result_entry = [ |
| 198 |
'id' => $new_id, |
| 199 |
'label' => $sanitized_item['label'], |
| 200 |
]; |
| 201 |
|
| 202 |
if ( 'rename' === $action ) { |
| 203 |
$result['renamed'][] = [ |
| 204 |
'import_entry' => $import_entry, |
| 205 |
'result_entry' => $result_entry, |
| 206 |
]; |
| 207 |
} else { |
| 208 |
$result['created'][] = [ |
| 209 |
'import_entry' => $import_entry, |
| 210 |
'result_entry' => $result_entry, |
| 211 |
]; |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
$changes = null; |
| 216 |
$has_changes = ! empty( $added_classes_order ) || ! empty( $modified_classes ) || ! empty( $deleted_classes ); |
| 217 |
|
| 218 |
if ( $has_changes ) { |
| 219 |
$new_order = array_merge( $added_classes_order, $previous_order ); |
| 220 |
$classes_repository->update_order_and_labels( $new_order, $added_classes_labels ); |
| 221 |
|
| 222 |
$changes = [ |
| 223 |
'added' => $added_classes_order, |
| 224 |
'deleted' => $deleted_classes, |
| 225 |
'modified' => $modified_classes, |
| 226 |
'order' => count( $added_classes_order ) > 0 || count( $deleted_classes ) > 0, |
| 227 |
]; |
| 228 |
} |
| 229 |
|
| 230 |
return [ |
| 231 |
'result' => $result, |
| 232 |
'changes' => $changes, |
| 233 |
]; |
| 234 |
} |
| 235 |
|
| 236 |
private static function create_new_class( array $sanitized_item ): void { |
| 237 |
$created = Global_Class_Post::create( $sanitized_item['id'], $sanitized_item['label'], $sanitized_item ); |
| 238 |
|
| 239 |
if ( $created ) { |
| 240 |
clean_post_cache( $created->get_post_id() ); |
| 241 |
} else { |
| 242 |
throw new \Exception( 'Failed to create new class: ' . esc_html( $sanitized_item['id'] ) . ' with label: ' . esc_html( $sanitized_item['label'] ) ); |
| 243 |
} |
| 244 |
} |
| 245 |
|
| 246 |
private static function replace_existing_class( string $existing_id, array $sanitized_item ): void { |
| 247 |
$post = Global_Class_Post::find_by_class_id( $existing_id ); |
| 248 |
|
| 249 |
if ( ! $post ) { |
| 250 |
throw new \Exception( 'Failed to find existing class: ' . esc_html( $existing_id ) ); |
| 251 |
} |
| 252 |
|
| 253 |
$post->set_preview( false ); |
| 254 |
$post->update_data( $sanitized_item ); |
| 255 |
Migrations_Orchestrator::clear_entity_migration_cache( $post->get_post_id(), Global_Classes_Repository::META_KEY_FRONTEND ); |
| 256 |
clean_post_cache( $post->get_post_id() ); |
| 257 |
} |
| 258 |
|
| 259 |
private static function validate_class_entry( $class_entry ): array { |
| 260 |
if ( ! is_array( $class_entry ) ) { |
| 261 |
return [ |
| 262 |
'is_valid' => false, |
| 263 |
'error' => self::ERROR_NOT_ARRAY, |
| 264 |
]; |
| 265 |
} |
| 266 |
|
| 267 |
$missing_fields = []; |
| 268 |
|
| 269 |
foreach ( [ 'id', 'label' ] as $field ) { |
| 270 |
if ( ! isset( $class_entry[ $field ] ) || ! is_string( $class_entry[ $field ] ) ) { |
| 271 |
$missing_fields[] = $field; |
| 272 |
} |
| 273 |
} |
| 274 |
|
| 275 |
if ( ! empty( $missing_fields ) ) { |
| 276 |
return [ |
| 277 |
'is_valid' => false, |
| 278 |
'error' => self::ERROR_MISSING_FIELDS . ':' . implode( ',', $missing_fields ), |
| 279 |
]; |
| 280 |
} |
| 281 |
|
| 282 |
return [ |
| 283 |
'is_valid' => true, |
| 284 |
'error' => null, |
| 285 |
]; |
| 286 |
} |
| 287 |
|
| 288 |
private static function resolve_item_action( |
| 289 |
array $class_entry, |
| 290 |
array $existing_label_to_id, |
| 291 |
string $conflict_resolution |
| 292 |
): string { |
| 293 |
$label_lower = strtolower( $class_entry['label'] ); |
| 294 |
$has_conflict = isset( $existing_label_to_id[ $label_lower ] ); |
| 295 |
|
| 296 |
if ( ! $has_conflict ) { |
| 297 |
return 'new'; |
| 298 |
} |
| 299 |
|
| 300 |
switch ( $conflict_resolution ) { |
| 301 |
case 'skip': |
| 302 |
return 'skip'; |
| 303 |
case 'replace': |
| 304 |
return 'replace'; |
| 305 |
case 'merge': |
| 306 |
return 'rename'; |
| 307 |
default: |
| 308 |
return self::DEFAULT_CONFLICT_RESOLUTION; |
| 309 |
} |
| 310 |
} |
| 311 |
|
| 312 |
private static function sanitize_item( string $item_id, array $item, Style_Parser $style_parser ): array { |
| 313 |
$item_result = $style_parser->parse( $item ); |
| 314 |
|
| 315 |
if ( ! $item_result->is_valid() ) { |
| 316 |
return [ |
| 317 |
'is_valid' => false, |
| 318 |
'error' => self::ERROR_INVALID_PROPS, |
| 319 |
'sanitized' => null, |
| 320 |
]; |
| 321 |
} |
| 322 |
|
| 323 |
$sanitized_item = $item_result->unwrap(); |
| 324 |
|
| 325 |
if ( $item_id !== $sanitized_item['id'] ) { |
| 326 |
return [ |
| 327 |
'is_valid' => false, |
| 328 |
'error' => self::ERROR_ID_MISMATCH, |
| 329 |
'sanitized' => null, |
| 330 |
]; |
| 331 |
} |
| 332 |
|
| 333 |
return [ |
| 334 |
'is_valid' => true, |
| 335 |
'error' => null, |
| 336 |
'sanitized' => $sanitized_item, |
| 337 |
]; |
| 338 |
} |
| 339 |
|
| 340 |
private static function build_label_to_id_map_from_labels( array $id_to_label ): array { |
| 341 |
$map = []; |
| 342 |
|
| 343 |
foreach ( $id_to_label as $id => $label ) { |
| 344 |
$map[ strtolower( $label ) ] = $id; |
| 345 |
} |
| 346 |
|
| 347 |
return $map; |
| 348 |
} |
| 349 |
|
| 350 |
private static function generate_unique_id( array $order_set ): string { |
| 351 |
do { |
| 352 |
$id = 'g-' . substr( bin2hex( random_bytes( 4 ) ), 0, 7 ); |
| 353 |
} while ( isset( $order_set[ $id ] ) ); |
| 354 |
|
| 355 |
return $id; |
| 356 |
} |
| 357 |
} |
| 358 |
|