Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlueprintImportService
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 12
3540
0.00% covered (danger)
0.00%
0 / 1
 import
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 validate_blueprint_resources_url
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 fetch_blueprint_zip
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 process_blueprint_zip
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 process_sql_file
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 search_replace
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 insert_sql
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 get_statements_from_sql
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 process_media_files
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
90
 map_user_posts
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 cleanup_temp_dir
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 remove_not_empty_directory
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Blueprint Import Service
4 *
5 * @package NewfoldLabs\WP\Module\Onboarding
6 */
7
8namespace NewfoldLabs\WP\Module\Onboarding\Services\Blueprints;
9
10/**
11 * Blueprint Import Service
12 * 
13 * This class handles the import of a blueprint package that is exported by...
14 * https://github.com/newfold-labs/wp-plugin-blueprint-exporter.
15 * 
16 * The blueprint package is a zip file that contains a SQL file and a folder with
17 * the media files.
18 * 
19 * The SQL file is processed to replace the source site URL with the target site URL.
20 * The media files are copied to the wp-content/uploads directory.
21 */
22class BlueprintImportService extends BlueprintsService {
23
24    /**
25     * The resources source of the blueprint.
26     *
27     * @var string
28     */
29    private $resources_url = '';
30
31    /**
32     * The temporary directory for the import.
33     *
34     * @var string
35     */
36    private $temp_dir = '';
37
38    /**
39     * Import a blueprint.
40     *
41     * @param string $blueprint_resources_url The zip file source of the blueprint.
42     * @return true|\WP_Error True if the blueprint is imported, WP_Error if there is an error.
43     */
44    public function import( string $blueprint_resources_url ) {
45        $this->resources_url = $blueprint_resources_url;
46        // Validate the blueprint resources source.
47        if ( ! $this->validate_blueprint_resources_url() ) {
48            return new \WP_Error( 'invalid_blueprint_resources_url', 'Invalid blueprint resources URL' );
49        }
50
51        try {
52            // Fetch the blueprint zip to be imported.
53            $blueprint_zip = $this->fetch_blueprint_zip();
54            if ( \is_wp_error( $blueprint_zip ) ) {
55                return $blueprint_zip;
56            }
57
58            // Unzip the blueprint zip.
59            $blueprint_processed = $this->process_blueprint_zip( $blueprint_zip );
60            if ( \is_wp_error( $blueprint_processed ) ) {
61                return $blueprint_processed;
62            }
63
64            return true;
65        } catch ( \Exception $e ) {
66            $this->cleanup_temp_dir();
67            return new \WP_Error( 'blueprint_import_error', 'Blueprint import failed' );
68        }
69    }
70
71    /**
72     * Validate the blueprint resources source.
73     *
74     * @return bool True if the blueprint resources source is valid, false otherwise.
75     */
76    private function validate_blueprint_resources_url(): bool {
77        $blueprint = $this->get_blueprint_by_resources_url( $this->resources_url );
78        if ( empty( $blueprint ) ) {
79            return false;
80        }
81
82        return true;
83    }
84
85    /**
86     * Fetch the blueprint zip to temp file.
87     *
88     * @return string|\WP_Error The download temp file path or an error if the blueprint zip is not found.
89     */
90    private function fetch_blueprint_zip() {
91        if ( ! function_exists( 'download_url' ) ) {
92            require_once ABSPATH . 'wp-admin/includes/file.php';
93        }
94
95        $zip_file = \download_url( $this->resources_url );
96        if ( \is_wp_error( $zip_file ) ) {
97            return new \WP_Error( 'blueprint_zip_download_failed', 'Blueprint zip download failed' );
98        }
99
100        return $zip_file;
101    }
102
103    /**
104     * Process the blueprint zip.
105     *
106     * @param string $zip_file The path to the zip file.
107     * @return true|\WP_Error True if the blueprint zip is processed, WP_Error if there is an error.
108     */
109    private function process_blueprint_zip( string $zip_file ) {
110        \WP_Filesystem();
111
112        /**
113         * The result of the blueprint import.
114         *
115         * @var true|\WP_Error
116         */
117        $result = true;
118
119        // Create the temporary working directory.
120        $this->temp_dir = \get_temp_dir() . 'blueprint-import/';
121        // Delete working directory if it exists (clear existing exports).
122        if ( is_dir( $this->temp_dir ) ) {
123            $this->cleanup_temp_dir();
124        }
125        // Create working directory.
126        if ( ! is_dir( $this->temp_dir ) ) {
127            \wp_mkdir_p( $this->temp_dir );
128        }
129
130        // Unzip the blueprint zip to the working directory.
131        $unzip_status = \unzip_file( $zip_file, $this->temp_dir );
132        if ( \is_wp_error( $unzip_status ) ) {
133            @unlink( $zip_file );
134            $result = new \WP_Error( 'blueprint_unzip_failed', 'Blueprint unzip failed' );
135        }
136        @unlink( $zip_file );
137
138        // Process the SQL file
139        $sql_processed = $this->process_sql_file();
140        if ( \is_wp_error( $sql_processed ) ) {
141            $result = $sql_processed;
142        }
143
144        // Process media files
145        $media_processed = $this->process_media_files();
146        if ( \is_wp_error( $media_processed ) ) {
147            $result = $media_processed;
148        }
149
150        // Clean up the temporary directory.
151        $this->cleanup_temp_dir();
152
153        return $result;
154    }
155
156    /**
157     * Process the SQL file.
158     *
159     * @return bool|\WP_Error True if the SQL file is processed, WP_Error if there is an error.
160     */
161    private function process_sql_file() {
162        // Get the SQL file.
163        $sql_file = $this->temp_dir . 'blueprint.sql';
164        if ( ! file_exists( $sql_file ) ) {
165            return new \WP_Error( 'sql_file_not_found', 'Blueprint SQL file not found' );
166        }
167
168        // Read the SQL file.
169        $sql_content = file_get_contents( $sql_file );
170        if ( false === $sql_content || empty( $sql_content ) ) {
171            return new \WP_Error( 'sql_file_read_error', 'Could not read SQL file' );
172        }
173
174        // Perform search and replace.
175        $processed_sql_content = $this->search_replace( $sql_content );
176        if ( \is_wp_error( $processed_sql_content ) ) {
177            return $processed_sql_content;
178        }
179
180        // Insert the SQL content.
181        $insert_sql = $this->insert_sql( $processed_sql_content );
182        if ( \is_wp_error( $insert_sql ) ) {
183            return $insert_sql;
184        }
185
186        // Map the user posts.
187        $this->map_user_posts();
188
189        return true;
190    }
191
192    /**
193     * Search and replace.
194     *
195     * @param string $sql_content The SQL content.
196     * @return string|\WP_Error The processed SQL content or an WP_Error if there is an error.
197     */
198    private function search_replace( string $sql_content ) {
199        global $wpdb;
200
201        /**
202         * Search and replace database prefix.
203         */
204        $sql_content = str_replace( '{{PREFIX}}', $wpdb->prefix, $sql_content );
205
206        /**
207         * Search and replace site URL.
208         */
209        // Get the source site URL.
210        $source_site_url = preg_match( '/-- Site URL: ([^\r\n]+)/', $sql_content, $matches );
211        if ( empty( $source_site_url ) ) {
212            return new \WP_Error( 'source_site_url_not_found', 'Source site URL not found' );
213        }
214        $source_site_url = trim( $matches[1] ?? '' );
215        $source_site_url = rtrim( $source_site_url, '/' );
216
217        // Get the target site URL.
218        $target_site_url = home_url();
219        $target_site_url = rtrim( $target_site_url, '/' );
220
221        // Replace URLs in content
222        $sql_content = str_replace( $source_site_url, $target_site_url, $sql_content );
223        // Also handle URLs with trailing slashes
224        $sql_content = str_replace( $source_site_url . '/', $target_site_url . '/', $sql_content );
225
226        /**
227         * Search and replace any non-secure urls with secure urls.
228         */
229        $non_secure_source_site_url = parse_url( $source_site_url, PHP_URL_HOST );
230        $non_secure_source_site_url = 'http://' . $non_secure_source_site_url;
231
232        // Replace URLs in content
233        $sql_content = str_replace( $non_secure_source_site_url, $target_site_url, $sql_content );
234        // Also handle URLs with trailing slashes
235        $sql_content = str_replace( $non_secure_source_site_url . '/', $target_site_url . '/', $sql_content );
236
237        if ( empty( $sql_content ) ) {
238            return new \WP_Error( 'sql_search_replace_failed', 'SQL search and replace failed' );
239        }
240
241        return $sql_content;
242    }
243
244    /**
245     * Insert the SQL content.
246     *
247     * @param string $sql_content The SQL content.
248     * @return bool|\WP_Error True if the SQL content is inserted, WP_Error if 75% or more of the SQL statements are not inserted.
249     */
250    private function insert_sql( string $sql_content ) {
251        global $wpdb;
252
253        // Extract the SQL statements from the SQL content.
254        $statements = $this->get_statements_from_sql( $sql_content );
255        if ( empty( $statements ) ) {
256            return new \WP_Error( 'invalid_sql_content', 'Invalid SQL content. No statements found.' );
257        }
258
259        // Status trackers.
260        $errors = [];
261        $successful_count = 0;
262        $total_statements = 0;
263
264        // Insert the SQL content.
265        foreach ( $statements as $statement ) {
266            $statement = trim( $statement );
267            // Skip empty statements, comments, and SET statements.
268            if ( empty( $statement ) || strpos( $statement, '--' ) === 0 || strpos( $statement, 'SET' ) === 0 ) {
269                continue;
270            }
271
272            $total_statements++;
273
274            $result = $wpdb->query( $statement );
275            if ( false === $result && ! empty( $wpdb->last_error ) ) {
276                // Record the error.
277                $errors[] = [
278                    'statement' => substr( $statement, 0, 100 ) . '...',
279                    'error' => $wpdb->last_error
280                ];
281            } else {
282                $successful_count++;
283            }
284        }
285
286        /**
287         * Determine success based on ratio of successful imports
288         */
289        $success_rate = $total_statements > 0 ? ( $successful_count / $total_statements ) : 0;
290
291        // If the success rate is less than 75%, return an error.
292        if ( $success_rate < 0.75 ) {
293            return new \WP_Error( 'sql_import_mostly_failed', 
294                sprintf( 'SQL import mostly failed. %d/%d statements succeeded.', $successful_count, $total_statements ),
295                $errors
296            );
297        }
298
299        return true;
300    }
301
302    /**
303     * Get the statements from the SQL content.
304     *
305     * @param string $sql_content The SQL content.
306     * @return array The statements.
307     */
308    private function get_statements_from_sql( string $sql_content ): array {
309        $statements = [];
310        $current_statement = '';
311        
312        // Split the entire SQL dump into individual lines
313        $lines = explode( "\n", $sql_content );
314
315        // Loop through each line to build statements
316        foreach ( $lines as $line ) {
317            $trimmed_line = trim( $line );
318
319            // Skip empty lines and comment lines
320            if ( empty( $trimmed_line ) || strpos( $trimmed_line, '--' ) === 0 || strpos( $trimmed_line, '#' ) === 0 ) {
321                continue;
322            }
323
324            // Append the line to the current statement
325            $current_statement .= $line . "\n";
326
327            // If a line ends with a semicolon, we've reached the end of a statement
328            if ( substr( $trimmed_line, -1 ) === ';' ) {
329                $statements[] = trim( $current_statement );
330                // Reset for the next statement
331                $current_statement = '';
332            }
333        }
334
335        return $statements;
336    }
337
338    /**
339     * Process the media files.
340     *
341     * @return bool|\WP_Error True if the media files are processed, WP_Error if there is an error.
342     */
343    private function process_media_files() {
344        $temp_uploads_dir = $this->temp_dir . 'uploads/';
345        if ( ! is_dir( $temp_uploads_dir ) ) {
346            // Return true if no uploads to import.
347            return true;
348        }
349
350        // Get wp-content/uploads directory.
351        $wp_uploads_dir = wp_upload_dir()['basedir'];
352        if ( ! is_dir( $wp_uploads_dir ) ) {
353            return new \WP_Error( 'uploads_dir_not_found', 'WordPress uploads directory not found' );
354        }
355
356        // Iterate through the uploads source and copy its contents to the wp-content/uploads directory.
357        $iterator = new \RecursiveIteratorIterator(
358            new \RecursiveDirectoryIterator( $temp_uploads_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
359            \RecursiveIteratorIterator::SELF_FIRST
360        );
361
362        try {
363            foreach ( $iterator as $file ) {
364                $source_path   = $file->getRealPath();
365                $relative_path = substr( $source_path, strlen( $temp_uploads_dir ) );
366                // Remove leading 'uploads/' if it exists in the relative path.
367                if ( strpos( $relative_path, 'uploads' . DIRECTORY_SEPARATOR ) === 0 ) {
368                    $relative_path = substr( $relative_path, strlen( 'uploads' . DIRECTORY_SEPARATOR ) );
369                }
370                $destination_path = $wp_uploads_dir . DIRECTORY_SEPARATOR . $relative_path;
371
372                if ( $file->isDir() ) {
373                    wp_mkdir_p( $destination_path );
374                } else {
375                    // Ensure destination directory exists
376                    $destination_file_dir = dirname( $destination_path );
377                    if ( ! is_dir( $destination_file_dir ) ) {
378                        wp_mkdir_p( $destination_file_dir );
379                    }
380
381                    // Copy the file (overwrite if it exists).
382                    if ( file_exists( $destination_path ) ) {
383                        unlink( $destination_path );
384                    }
385                    copy( $source_path, $destination_path );
386                }
387            }
388        } catch ( \Exception $e ) {
389            return new \WP_Error( 'blueprint_media_import_error', 'Media import failed: ' . $e->getMessage() );
390        }
391
392        return true;
393    }
394
395    /**
396     * Map wp_posts post_author rows to the current user.
397     *
398     * @return void
399     */
400    private function map_user_posts() {
401        global $wpdb;
402        $current_user_id = get_current_user_id();
403
404        $wpdb->query( $wpdb->prepare(
405            "UPDATE {$wpdb->posts} SET post_author = %d",
406            $current_user_id
407        ) );
408    }
409
410    /**
411     * Clean up the temporary directory.
412     * 
413     * @return bool True if the temporary directory is cleaned up, false otherwise.
414     */
415    private function cleanup_temp_dir(): bool {
416        $dir = $this->temp_dir;
417        if ( ! is_dir( $dir ) ) {
418            return true;
419        }
420
421        // Remove all files and subdirectories.
422        $files = array_diff( scandir( $dir ), array( '.', '..' ) );
423        foreach ( $files as $file ) {
424            $path = $dir . DIRECTORY_SEPARATOR . $file;
425            if ( is_dir( $path ) ) {
426                $this->remove_not_empty_directory( $path );
427            } else {
428                unlink( $path );
429            }
430        }
431
432        return rmdir( $dir );
433    }
434
435    /**
436     * Recursively remove a directory and all its contents.
437     *
438     * @param string $dir The directory path to remove
439     * @return bool True on success, false on failure
440     */
441    private function remove_not_empty_directory( string $dir ): bool {
442        if ( ! is_dir( $dir ) ) {
443            return true;
444        }
445
446        // Remove all files and subdirectories.
447        $files = array_diff( scandir( $dir ), array( '.', '..' ) );
448        foreach ( $files as $file ) {
449            $path = $dir . DIRECTORY_SEPARATOR . $file;
450            if ( is_dir( $path ) ) {
451                $this->remove_not_empty_directory( $path );
452            } else {
453                unlink( $path );
454            }
455        }
456
457        return rmdir( $dir );
458    }
459}