diff --git a/src/php/Integration/Promotions/Notices/Elementor.php b/src/php/Integration/Promotions/Notices/Elementor.php index 77086fb5..c3dcadf8 100644 --- a/src/php/Integration/Promotions/Notices/Elementor.php +++ b/src/php/Integration/Promotions/Notices/Elementor.php @@ -2,6 +2,8 @@ namespace Code_Snippets\Integration\Promotions\Notices; +use Code_Snippets\REST_API\Import\Plugins\Elementor_Custom_Code_Plugin_Importer; + /** * Promotion class for Elementor Custom Code. * @@ -62,4 +64,15 @@ public function get_promotion_heading(): string { public function get_promotion_message(): string { return __( 'Move your custom logic into a dedicated dashboard built for professionals. Experience a cleaner workflow with advanced security and global organization.', 'code-snippets' ); } + + /** + * Check if the user should see the migration button. + * + * @return bool Whether the user should see the migration button. + */ + public function show_migration_button(): bool { + $importer = new Elementor_Custom_Code_Plugin_Importer(); + $data = $importer->get_data(); + return ! empty( $data ); + } } diff --git a/src/php/REST_API/Import/Plugins/Elementor_Custom_Code_Plugin_Importer.php b/src/php/REST_API/Import/Plugins/Elementor_Custom_Code_Plugin_Importer.php new file mode 100644 index 00000000..503bcade --- /dev/null +++ b/src/php/REST_API/Import/Plugins/Elementor_Custom_Code_Plugin_Importer.php @@ -0,0 +1,499 @@ + 'name', + 'code' => 'code', + 'import_location' => 'scope', + 'priority' => 'priority', + 'modified' => 'modified', + 'desc' => 'desc', + ]; + + /** + * Get the unique name of the importer. + * + * @return string + */ + public function get_name(): string { + return 'elementor'; + } + + /** + * Get the human-readable title of the importer. + * + * @return string + */ + public function get_title(): string { + return esc_html__( 'Elementor Custom Code', 'code-snippets' ); + } + + /** + * Check whether Elementor Custom Code snippets are available on this site. + * + * @return bool + */ + public static function is_active(): bool { + return post_type_exists( self::POST_TYPE ); + } + + /** + * Retrieve snippet data from Elementor Custom Code posts. + * + * @param array $ids_to_import Optional array of post IDs to import. + * + * @return array + */ + public function get_data( array $ids_to_import = [] ): array { + $query_args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => [ 'publish', 'draft', 'pending', 'private' ], + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + if ( ! empty( $ids_to_import ) ) { + $query_args['post__in'] = array_map( 'absint', $ids_to_import ); + } + + $posts = get_posts( $query_args ); + $data = []; + + foreach ( $posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } + + $location_raw = $this->read_location_raw( $post->ID ); + $code_kind = $this->read_code_kind( $post ); + $desc = $this->build_description( $post, $location_raw ); + + $data[] = [ + 'name' => $post->post_title, + 'code' => $this->read_snippet_code( $post ), + 'import_location' => $location_raw, + 'code_kind' => $code_kind, + 'priority' => $this->read_priority( $post->ID ), + 'modified' => $post->post_modified, + 'desc' => $desc, + 'table_data' => [ + 'id' => $post->ID, + 'title' => $post->post_title, + ], + ]; + } + + return $data; + } + + /** + * Create a Snippet object from the provided snippet data. + * + * @param array $snippet_data Snippet data object. + * @param bool $multisite Whether the snippet is for a multisite network. + * + * @return Snippet|null The created Snippet object or null if unsupported. + */ + public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet { + $kind = $snippet_data['code_kind'] ?? ''; + + if ( ! in_array( $kind, [ 'html', 'css', 'js' ], true ) ) { + return null; + } + + return parent::create_snippet( $snippet_data, $multisite ); + } + + /** + * Transform field value to match Code Snippets format. + * + * @param string $target_field Name of field. + * @param mixed $value Field value. + * @param array $snippet_data Snippet data. + * + * @return mixed|null + */ + protected function transform_field_value( string $target_field, $value, array $snippet_data ) { + if ( 'scope' === $target_field ) { + return $this->transform_scope_value( $value, $snippet_data ); + } + + if ( 'code' === $target_field ) { + return $this->transform_code_value( (string) $value, $snippet_data ); + } + + return $value; + } + + /** + * Map Elementor location + code kind to a Code Snippets scope. + * + * @param mixed $location_value Raw location meta from Elementor. + * @param array $snippet_data Full row from {@see get_data()}. + * + * @return string|null + */ + private function transform_scope_value( $location_value, array $snippet_data ): ?string { + $kind = $snippet_data['code_kind'] ?? ''; + $bucket = $this->normalize_location_bucket( is_scalar( $location_value ) ? (string) $location_value : '' ); + + switch ( $bucket ) { + case 'head': + if ( 'html' === $kind ) { + return 'head-content'; + } + if ( 'css' === $kind ) { + return 'site-css'; + } + if ( 'js' === $kind ) { + return 'site-head-js'; + } + break; + case 'body_start': + if ( 'html' === $kind ) { + return 'footer-content'; + } + if ( 'css' === $kind ) { + return 'site-css'; + } + if ( 'js' === $kind ) { + return 'site-footer-js'; + } + break; + case 'body_end': + if ( 'html' === $kind ) { + return 'footer-content'; + } + if ( 'css' === $kind ) { + return 'site-css'; + } + if ( 'js' === $kind ) { + return 'site-footer-js'; + } + break; + default: + break; + } + + return null; + } + + /** + * Reduce Elementor location strings to a small set of buckets. + * + * Elementor Pro stores {@see META_LOCATION} as `elementor_head`, `elementor_body_start`, or + * `elementor_body_end`. Older or mistaken keys may use `head`, `body_start`, `body_end`, etc. + * + * @param string $location Raw location meta. + * + * @return string One of head, body_start, body_end, unknown. + */ + private function normalize_location_bucket( string $location ): string { + $s = strtolower( trim( $location ) ); + + if ( '' === $s ) { + return 'unknown'; + } + + $slug_map = [ + 'elementor_head' => 'head', + 'elementor_body_start' => 'body_start', + 'elementor_body_end' => 'body_end', + 'head' => 'head', + 'header' => 'head', + 'page_head' => 'head', + 'head_tag' => 'head', + 'body_start' => 'body_start', + 'body-start' => 'body_start', + 'body_open' => 'body_start', + 'body-open' => 'body_start', + 'wp_body_open' => 'body_start', + 'body_end' => 'body_end', + 'body-end' => 'body_end', + 'body_close' => 'body_end', + 'before_body_close' => 'body_end', + ]; + + if ( isset( $slug_map[ $s ] ) ) { + return $slug_map[ $s ]; + } + + if ( preg_match( '/^\d+$/', $s ) ) { + return $this->normalize_location_numeric_bucket( $s ); + } + + if ( preg_match( '/\b(head|header)\b/i', $s ) && ! preg_match( '/\bbody\b/i', $s ) ) { + return 'head'; + } + + if ( preg_match( '/body[\s_-]*start|start[\s_-]*body|body[\s_-]*open|wp_body|after_body|beginning[^\w]*of[^\w]*body/i', $s ) ) { + return 'body_start'; + } + + if ( preg_match( '/body[\s_-]*end|end[\s_-]*body|before[^\w]*<\/\s*body|wp_footer/i', $s ) ) { + return 'body_end'; + } + + if ( preg_match( '/\bhead\b/i', $s ) ) { + return 'head'; + } + + return 'unknown'; + } + + /** + * Map legacy or alternate numeric location IDs to buckets (when meta stores integers as strings). + * + * @param string $digits Only digits. + * + * @return string + */ + private function normalize_location_numeric_bucket( string $digits ): string { + switch ( $digits ) { + case '1': + return 'head'; + case '2': + return 'body_start'; + case '3': + return 'body_end'; + default: + return 'unknown'; + } + } + + /** + * Decode entities; strip outer script/style wrappers when importing JS/CSS scopes. + * + * @param string $code_value Post content. + * @param array $snippet_data Snippet data. + * + * @return string + */ + private function transform_code_value( string $code_value, array $snippet_data ): string { + $code = html_entity_decode( $code_value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + $kind = $snippet_data['code_kind'] ?? ''; + + if ( 'css' === $kind ) { + $code = preg_replace( '/<\s*style[^>]*>|<\s*\/\s*style\s*>/i', '', $code ); + } + + if ( 'js' === $kind ) { + $code = preg_replace( '/<\s*script[^>]*>|<\s*\/\s*script\s*>/i', '', $code ); + } + + return trim( $code ); + } + + /** + * Read Elementor snippet priority from post meta (first non-empty known key). + * + * @param int $post_id Post ID. + * + * @return int + */ + private function read_priority( int $post_id ): int { + foreach ( self::META_PRIORITY_KEYS as $key ) { + $raw = get_post_meta( $post_id, $key, true ); + if ( '' !== $raw && null !== $raw && false !== $raw ) { + $priority = absint( $raw ); + return max( 1, min( 100, $priority ? $priority : 10 ) ); + } + } + + return 10; + } + + /** + * Location meta: Elementor Pro uses `_elementor_location`; fall back to legacy mistaken key. + * + * @param int $post_id Post ID. + * + * @return string + */ + private function read_location_raw( int $post_id ): string { + $primary = get_post_meta( $post_id, self::META_LOCATION, true ); + if ( is_string( $primary ) && '' !== trim( $primary ) ) { + return trim( $primary ); + } + + $legacy = get_post_meta( $post_id, self::META_LOCATION_LEGACY, true ); + if ( is_string( $legacy ) && '' !== trim( $legacy ) ) { + return trim( $legacy ); + } + + return ''; + } + + /** + * Snippet body: Elementor Pro stores code in `_elementor_code`; {@see META_CODE}. + * + * @param WP_Post $post Post object. + * + * @return string + */ + private function read_snippet_code( WP_Post $post ): string { + $from_meta = get_post_meta( $post->ID, self::META_CODE, true ); + if ( is_string( $from_meta ) && '' !== trim( $from_meta ) ) { + return $from_meta; + } + + return is_string( $post->post_content ) ? $post->post_content : ''; + } + + /** + * Resolve html / css / js from optional legacy meta or from stored snippet code. + * + * @param WP_Post $post Post object. + * + * @return string One of html, css, js. + */ + private function read_code_kind( WP_Post $post ): string { + foreach ( self::META_CODE_KIND_KEYS as $key ) { + $raw = get_post_meta( $post->ID, $key, true ); + if ( is_string( $raw ) && '' !== trim( $raw ) ) { + $normalized = $this->normalize_code_kind( $raw ); + if ( '' !== $normalized ) { + return $normalized; + } + } + } + + return $this->infer_code_kind_from_content( $this->read_snippet_code( $post ) ); + } + + /** + * Map Elementor type strings to html, css, or js. + * + * @param string $raw Raw meta value. + * + * @return string Empty if unknown. + */ + private function normalize_code_kind( string $raw ): string { + $s = strtolower( trim( $raw ) ); + + if ( in_array( $s, [ 'html', 'text', 'markup' ], true ) ) { + return 'html'; + } + + if ( in_array( $s, [ 'css', 'stylesheet', 'style' ], true ) ) { + return 'css'; + } + + if ( in_array( $s, [ 'js', 'javascript', 'script' ], true ) ) { + return 'js'; + } + + return ''; + } + + /** + * Guess code kind when meta is missing. + * + * @param string $content Post content. + * + * @return string One of html, css, js. + */ + private function infer_code_kind_from_content( string $content ): string { + $trim = trim( $content ); + + if ( preg_match( '/^\s*<\s*script\b/i', $trim ) ) { + return 'js'; + } + + if ( preg_match( '/^\s*<\s*style\b/i', $trim ) ) { + return 'css'; + } + + return 'html'; + } + + /** + * Optional description noting Elementor conditions and imperfect body-start mapping. + * + * @param WP_Post $post Snippet post. + * @param string $location_raw Raw location meta. + * + * @return string + */ + private function build_description( WP_Post $post, string $location_raw ): string { + $parts = []; + + if ( '' !== $location_raw ) { + $parts[] = sprintf( + /* translators: %s: Elementor code location label or raw value */ + __( 'Elementor location: %s', 'code-snippets' ), + $location_raw + ); + } + + if ( 'body_start' === $this->normalize_location_bucket( $location_raw ) ) { + $parts[] = __( 'Imported from Elementor “Body - Start”. Review the snippet scope in Code Snippets if output order should differ.', 'code-snippets' ); + } + + $conditions_note = $this->read_display_conditions_note( $post->ID ); + if ( '' !== $conditions_note ) { + $parts[] = $conditions_note; + } + + return implode( "\n\n", array_filter( $parts ) ); + } + + /** + * Append a short note when Elementor stores display conditions in meta. + * + * @param int $post_id Post ID. + * + * @return string + */ + private function read_display_conditions_note( int $post_id ): string { + $keys = [ + '_elementor_conditions', + '_elementor_snippet_conditions', + '_elementor_custom_code_conditions', + ]; + + foreach ( $keys as $key ) { + $raw = get_post_meta( $post_id, $key, true ); + if ( ! empty( $raw ) ) { + return __( 'Elementor display conditions were not converted. Recreate conditions using Code Snippets if needed.', 'code-snippets' ); + } + } + + return ''; + } +} diff --git a/src/php/REST_API/Import/Plugins_Import_REST_Controller.php b/src/php/REST_API/Import/Plugins_Import_REST_Controller.php index c4b22c6a..5caa4123 100644 --- a/src/php/REST_API/Import/Plugins_Import_REST_Controller.php +++ b/src/php/REST_API/Import/Plugins_Import_REST_Controller.php @@ -2,6 +2,7 @@ namespace Code_Snippets\REST_API\Import; +use Code_Snippets\REST_API\Import\Plugins\Elementor_Custom_Code_Plugin_Importer; use Code_Snippets\REST_API\Import\Plugins\Header_Footer_Code_Manager_Plugin_Importer; use Code_Snippets\REST_API\Import\Plugins\Insert_Headers_And_Footers_Plugin_Importer; use Code_Snippets\REST_API\Import\Plugins\Insert_PHP_Code_Snippet_Plugin_Importer; @@ -41,6 +42,7 @@ public function __construct() { parent::__construct(); $this->plugin_importers = [ + 'elementor' => new Elementor_Custom_Code_Plugin_Importer(), 'insert-headers-and-footers' => new Insert_Headers_And_Footers_Plugin_Importer(), 'header-footer-code-manager' => new Header_Footer_Code_Manager_Plugin_Importer(), 'insert-php-code-snippet' => new Insert_PHP_Code_Snippet_Plugin_Importer(), diff --git a/tests/phpunit/test-elementor-custom-code-importer.php b/tests/phpunit/test-elementor-custom-code-importer.php new file mode 100644 index 00000000..f8025da2 --- /dev/null +++ b/tests/phpunit/test-elementor-custom-code-importer.php @@ -0,0 +1,181 @@ + false, + ] + ); + } + + /** + * Tear down: unregister CPT. + * + * @return void + */ + public function tear_down() { + unregister_post_type( 'elementor_snippet' ); + parent::tear_down(); + } + + /** + * Importer lists Elementor-like posts with expected keys (Elementor Pro meta keys). + * + * @return void + */ + public function test_get_data_returns_table_data_and_maps_head_html_to_scope() { + $post_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'GA Tag', + 'post_content' => '', + 'post_status' => 'publish', + ] + ); + + update_post_meta( $post_id, '_elementor_location', 'elementor_head' ); + update_post_meta( $post_id, '_elementor_code', '' ); + update_post_meta( $post_id, '_elementor_priority', 1 ); + + $importer = new Elementor_Custom_Code_Plugin_Importer(); + $data = $importer->get_data(); + + $this->assertCount( 1, $data ); + $row = $data[0]; + $this->assertSame( 'GA Tag', $row['table_data']['title'] ); + $this->assertSame( $post_id, $row['table_data']['id'] ); + $this->assertSame( '', $row['code'] ); + + $snippet = $importer->create_snippet( $row, false ); + $this->assertNotNull( $snippet ); + $this->assertSame( 'head-content', $snippet->scope ); + } + + /** + * Elementor Pro location enums map to scopes for body start/end. + * + * @return void + */ + public function test_body_start_and_body_end_slugs_map_to_scopes() { + $start_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'Body Start', + 'post_content' => '', + 'post_status' => 'publish', + ] + ); + update_post_meta( $start_id, '_elementor_location', 'elementor_body_start' ); + update_post_meta( $start_id, '_elementor_code', '
' ); + + $end_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'Body End', + 'post_content' => '', + 'post_status' => 'publish', + ] + ); + update_post_meta( $end_id, '_elementor_location', 'elementor_body_end' ); + update_post_meta( $end_id, '_elementor_code', '' ); + + $importer = new Elementor_Custom_Code_Plugin_Importer(); + + $start_row = $importer->get_data( [ $start_id ] )[0]; + $end_row = $importer->get_data( [ $end_id ] )[0]; + + $this->assertSame( 'footer-content', $importer->create_snippet( $start_row, false )->scope ); + $this->assertSame( 'footer-content', $importer->create_snippet( $end_row, false )->scope ); + } + + /** + * Head + JS maps to site-head-js. + * + * @return void + */ + public function test_head_js_maps_to_site_head_js() { + $post_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'Head JS', + 'post_content' => '', + 'post_status' => 'publish', + ] + ); + + update_post_meta( $post_id, '_elementor_location', 'elementor_head' ); + update_post_meta( $post_id, '_elementor_code', 'window.x=1;' ); + + $importer = new Elementor_Custom_Code_Plugin_Importer(); + $row = $importer->get_data()[0]; + $snippet = $importer->create_snippet( $row, false ); + + $this->assertSame( 'site-head-js', $snippet->scope ); + } + + /** + * Unknown location meta cannot map to a Code Snippets scope. + * + * @return void + */ + public function test_unknown_location_returns_null_snippet() { + $post_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'Bad location', + 'post_content' => '', + 'post_status' => 'publish', + ] + ); + + update_post_meta( $post_id, '_elementor_location', 'unknown_future_location_value' ); + update_post_meta( $post_id, '_elementor_code', 'x
' ); + + $importer = new Elementor_Custom_Code_Plugin_Importer(); + $row = $importer->get_data()[0]; + $this->assertNull( $importer->create_snippet( $row, false ) ); + } + + /** + * Legacy mistaken location meta still resolves when Pro meta is empty. + * + * @return void + */ + public function test_legacy_code_location_meta_fallback() { + $post_id = self::factory()->post->create( + [ + 'post_type' => 'elementor_snippet', + 'post_title' => 'Legacy', + 'post_content' => 'x
', + 'post_status' => 'publish', + ] + ); + + update_post_meta( $post_id, '_elementor_code_location', 'head' ); + update_post_meta( $post_id, '_elementor_code', 'y
' ); + + $importer = new Elementor_Custom_Code_Plugin_Importer(); + $row = $importer->get_data()[0]; + + $this->assertSame( 'head-content', $importer->create_snippet( $row, false )->scope ); + } +}