Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SiteGenImageService
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 5
1332
0.00% covered (danger)
0.00%
0 / 1
 process_homepage_images_immediate_async
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 extract_all_image_urls
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 upload_images_to_wp_media_library
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
342
 update_post_content_with_new_image_urls
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 connect_to_filesystem
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace NewfoldLabs\WP\Module\Onboarding\Services;
4
5use NewfoldLabs\WP\Module\Onboarding\TaskManagers\ImageSideloadTaskManager;
6use NewfoldLabs\WP\Module\Onboarding\Tasks\ImageSideloadTask;
7
8/**
9 * SiteGenImageService for the onboarding module.
10 *
11 * Handles image processing and media library operations for the onboarding flow.
12 */
13class SiteGenImageService {
14
15    /**
16     * Process homepage images immediately in background (non-blocking).
17     * This method dispatches an async request that doesn't block the main request.
18     *
19     * @param int    $post_id The post ID to process images for.
20     * @param string $content The content containing images.
21     */
22    public static function process_homepage_images_immediate_async( $post_id, $content ) {
23        // Extract image URLs from content
24        $image_urls = self::extract_all_image_urls( $content );
25
26        if ( empty( $image_urls ) ) {
27            return;
28        }
29
30        // Create and add task to queue
31        $task = new ImageSideloadTask( $post_id, $image_urls );
32        ImageSideloadTaskManager::add_to_queue( $task );
33
34        // Schedule a single event to process the queue (if not already scheduled)
35        if ( ! wp_next_scheduled( 'nfd_process_image_sideload_queue' ) ) {
36            wp_schedule_single_event( time(), 'nfd_process_image_sideload_queue' );
37        }
38    }
39
40    /**
41     * Extract all image URLs from content specifically targeting Unsplash and patterns.hiive.cloud domains.
42     *
43     * @param string $content The content to extract image URLs from.
44     * @return array Array of unique image URLs.
45     */
46    private static function extract_all_image_urls( $content ) {
47        $image_urls = array();
48
49        // Extract Unsplash images
50        preg_match_all( '/https?:\/\/([^\/]+\.)?unsplash\.com\/[^\s"\'<>]+/i', $content, $matches );
51        if ( isset( $matches[0] ) ) {
52            $image_urls = array_merge( $image_urls, $matches[0] );
53        }
54
55        // Extract patterns.hiive.cloud images
56        preg_match_all( '/https?:\/\/patterns\.hiive\.cloud\/[^\s"\'<>]+/i', $content, $matches );
57        if ( isset( $matches[0] ) ) {
58            $image_urls = array_merge( $image_urls, $matches[0] );
59        }
60
61        // Decode HTML entities in URLs to ensure proper replacement
62        $image_urls = array_map( 'html_entity_decode', $image_urls );
63
64        return array_values( array_unique( $image_urls ) );
65    }
66
67    /**
68     * Uploads images to the WordPress media library as attachments.
69     *
70     * This function takes an array of image URLs, downloads them, and
71     * uploads them to the WordPress media library, returning the URLs
72     * of the newly uploaded images.
73     *
74     * @param array $image_urls An array of image URLs to upload.
75     * @param int   $post_id The post ID to attach the images to.
76     * @return array|false An array of WordPress attachment URLs on success, false on failure.
77     * @throws Exception If there is an error during the upload process.
78     */
79    public static function upload_images_to_wp_media_library( $image_urls, $post_id ) {
80        require_once ABSPATH . 'wp-admin/includes/media.php';
81        require_once ABSPATH . 'wp-admin/includes/image.php';
82
83        global $wp_filesystem;
84        self::connect_to_filesystem();
85
86        $uploaded_image_urls = array();
87        $total_images        = count( $image_urls );
88        $successful_uploads  = 0;
89
90        try {
91            foreach ( $image_urls as $image_url ) {
92                // Check if the URL is valid.
93                if ( ! filter_var( $image_url, FILTER_VALIDATE_URL ) ) {
94                    continue;
95                }
96
97                // Fetch the image via remote get with timeout and a retry attempt.
98                $attempt      = 0;
99                $max_attempts = 2;
100                while ( $attempt < $max_attempts ) {
101                    $response = wp_remote_get( $image_url, array( 'timeout' => 15 ) );
102                    if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
103                        break;
104                    }
105                    ++$attempt;
106                }
107                if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
108                    continue;
109                }
110                // Reading the headers from the image url to determine.
111                $headers      = wp_remote_retrieve_headers( $response );
112                $content_type = $headers['content-type'] ?? '';
113                $image_data   = wp_remote_retrieve_body( $response );
114                if ( empty( $content_type ) || empty( $image_data ) ) {
115                    continue;
116                }
117                // Determine the file extension based on MIME type.
118                $file_extension = '';
119                switch ( $content_type ) {
120                    case 'image/jpeg':
121                        $file_extension = '.jpg';
122                        break;
123                    case 'image/png':
124                        $file_extension = '.png';
125                        break;
126                    case 'image/gif':
127                        $file_extension = '.gif';
128                        break;
129                    case 'image/webp':
130                        $file_extension = '.webp';
131                        break;
132                }
133
134                if ( '' === $file_extension ) {
135                    continue;
136                }
137                // create upload directory.
138                $upload_dir = wp_upload_dir();
139                // xtract a filename from the URL.
140                $parsed_url = wp_parse_url( $image_url );
141                $path_parts = pathinfo( $parsed_url['path'] );
142                // filename to be added in directory.
143                $original_filename = $path_parts['filename'] . $file_extension;
144
145                // to ensure the filename is unique within the upload directory.
146                $filename = wp_unique_filename( $upload_dir['path'], $original_filename );
147                $filepath = $upload_dir['path'] . '/' . $filename;
148
149                $wp_filesystem->put_contents( $filepath, $image_data );
150
151                // Create an attachment post for the image, metadata needed for WordPress media library.
152                // guid -for url, post_title for cleaned up name, post content is empty as this is an attachment.
153                // post_status inherit is for visibility.
154                $attachment = array(
155                    'guid'           => $upload_dir['url'] . '/' . $filename,
156                    'post_mime_type' => $content_type,
157                    'post_title'     => preg_replace( '/\.[^.]+$/', '', basename( $filename ) ),
158                    'post_content'   => '',
159                    'post_status'    => 'inherit',
160                    'post_parent'    => $post_id, // Attach to the specified post
161                );
162                $attach_id  = wp_insert_attachment( $attachment, $filepath );
163
164                // Generate and assign metadata for the attachment.
165                $attach_data = wp_generate_attachment_metadata( $attach_id, $filepath );
166                wp_update_attachment_metadata( $attach_id, $attach_data );
167
168                // Add the WordPress attachment URL to the list.
169                if ( $attach_id ) {
170                    $attachment_url = wp_get_attachment_url( $attach_id );
171                    if ( ! $attachment_url ) {
172                        $attachment_url = null;
173                    }
174                    $uploaded_image_urls[ $image_url ] = $attachment_url;
175                    $successful_uploads++;
176                }
177            }
178        } catch ( \Exception $e ) {
179            // Log error silently
180        }
181        return $uploaded_image_urls;
182    }
183
184    /**
185     * Update post content by replacing original image URLs with WordPress media library URLs.
186     *
187     * @param int   $post_id The post ID to update.
188     * @param array $url_mapping Array mapping original URLs to new WordPress URLs.
189     * @return bool True on success, false on failure.
190     */
191    public static function update_post_content_with_new_image_urls( $post_id, $url_mapping ) {
192        // Get the current post content
193        $post = get_post( $post_id );
194        if ( ! $post ) {
195            return false;
196        }
197
198        $content        = $post->post_content;
199        $updated        = false;
200        $replaced_count = 0;
201
202        // Replace each original URL with the new WordPress URL
203        foreach ( $url_mapping as $original_url => $new_url ) {
204            if ( ! empty( $new_url ) ) {
205                // Use str_replace for exact URL replacement
206                $new_content = str_replace( $original_url, $new_url, $content );
207
208                // If no replacement happened, try with HTML entity encoded version
209                if ( $new_content === $content ) {
210                    $encoded_url = htmlspecialchars( $original_url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
211                    $new_content = str_replace( $encoded_url, $new_url, $content );
212                }
213
214                // If still no replacement, try with double-encoded version (common in WordPress)
215                if ( $new_content === $content ) {
216                    $double_encoded_url = htmlspecialchars( htmlspecialchars( $original_url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
217                    $new_content        = str_replace( $double_encoded_url, $new_url, $content );
218                }
219
220                if ( $new_content !== $content ) {
221                    $content = $new_content;
222                    $updated = true;
223                    $replaced_count++;
224                }
225            }
226        }
227
228        // Update the post if content changed
229        if ( $updated ) {
230            $update_result = wp_update_post(
231                array(
232                    'ID'           => $post_id,
233                    'post_content' => $content,
234                )
235            );
236
237            if ( is_wp_error( $update_result ) ) {
238                return false;
239            }
240
241            return true;
242        }
243
244        return true; // No changes needed
245    }
246
247    /**
248     * Connect to the WordPress filesystem.
249     *
250     * @return boolean
251     */
252    public static function connect_to_filesystem() {
253        require_once ABSPATH . 'wp-admin/includes/file.php';
254
255        // We want to ensure that the user has direct access to the filesystem.
256        $access_type = \get_filesystem_method();
257        if ( 'direct' !== $access_type ) {
258            return false;
259        }
260
261        $creds = \request_filesystem_credentials( site_url() . '/wp-admin', '', false, false, array() );
262
263        if ( ! \WP_Filesystem( $creds ) ) {
264            return false;
265        }
266
267        return true;
268    }
269}