PluginProbe
Code Snippets / 4.0.0-beta.1
Code Snippets v4.0.0-beta.1
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / php / REST_API / Import / File_Import_REST_Controller.php

File_Import_REST_Controller.php in Code Snippets 4.0.0-beta.1, at php/REST_API/Import/File_Import_REST_Controller.php

473 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Code_Snippets\REST_API\Import;
4
5 use Code_Snippets\Model\Snippet;
6 use Code_Snippets\REST_API\REST_Controller;
7 use DOMDocument;
8 use WP_Error;
9 use WP_REST_Request;
10 use WP_REST_Response;
11 use WP_REST_Server;
12 use function Code_Snippets\get_snippets;
13 use function Code_Snippets\save_snippet;
14
15 /**
16 * Manages the import of code snippets from uploaded files via REST API.
17 */
18 class File_Import_REST_Controller extends REST_Controller {
19
20 /**
21 * Current API version.
22 */
23 public const VERSION = 1;
24
25 /**
26 * The base of this controller's route.
27 */
28 public const BASE_ROUTE = 'import/file-upload';
29
30 /**
31 * Registers REST API routes for file import.
32 */
33 public function register_routes() {
34 register_rest_route(
35 $this->namespace,
36 self::BASE_ROUTE . '/parse',
37 [
38 'methods' => WP_REST_Server::CREATABLE,
39 'callback' => [ $this, 'parse_uploaded_files' ],
40 'permission_callback' => [ $this, 'permission_callback' ],
41 ]
42 );
43
44 register_rest_route(
45 $this->namespace,
46 self::BASE_ROUTE . '/import',
47 [
48 'methods' => WP_REST_Server::CREATABLE,
49 'callback' => [ $this, 'import_selected_snippets' ],
50 'permission_callback' => [ $this, 'permission_callback' ],
51 'args' => [
52 'snippets' => [
53 'description' => __( 'Snippet data to import', 'code-snippets' ),
54 'type' => 'array',
55 'required' => true,
56 ],
57 'duplicate_action' => [
58 'description' => __( 'Action to take when duplicate snippets are found', 'code-snippets' ),
59 'type' => 'string',
60 'enum' => [ 'ignore', 'replace', 'skip' ],
61 'default' => 'ignore',
62 ],
63 'network' => [
64 'description' => __( 'Whether to import to network table', 'code-snippets' ),
65 'type' => 'boolean',
66 'default' => false,
67 ],
68 ],
69 ]
70 );
71 }
72
73 /**
74 * Parses uploaded files and extracts code snippets.
75 *
76 * @param WP_REST_Request $request The REST request.
77 *
78 * @return WP_Error|WP_REST_Response Parsed snippets or error.
79 */
80 public function parse_uploaded_files( WP_REST_Request $request ) {
81 $nonce = $request->get_header( 'X-WP-Nonce' );
82
83 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
84 return new WP_Error(
85 'rest_cookie_invalid_nonce',
86 __( 'Cookie check failed', 'code-snippets' ),
87 [ 'status' => 403 ]
88 );
89 }
90
91 $file_params = $request->get_file_params();
92
93 if ( empty( $file_params ) || empty( $file_params['files'] ) ) {
94 return new WP_Error(
95 'no_files',
96 __( 'No files were uploaded.', 'code-snippets' ),
97 [ 'status' => 400 ]
98 );
99 }
100
101 $files = $file_params['files'];
102
103 if ( ! isset( $files['name'], $files['type'], $files['tmp_name'], $files['error'] ) ) {
104 return new WP_Error(
105 'invalid_file_data',
106 __( 'Invalid file upload data.', 'code-snippets' ),
107 [ 'status' => 400 ]
108 );
109 }
110
111 $all_snippets = [];
112 $errors = [];
113
114 $file_count = is_array( $files['name'] ) ? count( $files['name'] ) : 1;
115
116 for ( $i = 0; $i < $file_count; $i++ ) {
117 $file_name = is_array( $files['name'] ) ? $files['name'][ $i ] : $files['name'];
118 $file_type = is_array( $files['type'] ) ? $files['type'][ $i ] : $files['type'];
119 $file_tmp = is_array( $files['tmp_name'] ) ? $files['tmp_name'][ $i ] : $files['tmp_name'];
120 $file_error = is_array( $files['error'] ) ? $files['error'][ $i ] : $files['error'];
121
122 if ( UPLOAD_ERR_OK !== $file_error ) {
123 /* translators: %1$s: file name, %2$s: error message */
124 $error_message = __( 'Upload error for file %1$s: %2$s', 'code-snippets' );
125 $errors[] = sprintf( $error_message, $file_name, $this->get_upload_error_message( $file_error ) );
126 continue;
127 }
128
129 $file_info = pathinfo( $file_name );
130 $extension = strtolower( $file_info['extension'] ?? '' );
131 $mime_type = sanitize_mime_type( $file_type );
132
133 if ( ! $this->is_valid_file_type( $extension, $mime_type ) ) {
134 /* translators: %s: file name */
135 $error_message = __( 'Invalid file type for %s. Only JSON and XML files are allowed.', 'code-snippets' );
136 $errors[] = sprintf( $error_message, $file_name );
137 continue;
138 }
139
140 $snippets = $this->parse_file_content( $file_tmp, $extension, $mime_type, $file_name );
141
142 if ( is_wp_error( $snippets ) ) {
143 /* translators: %1$s: file name, %2$s: error message */
144 $error_message = __( 'Error parsing %1$s: %2$s', 'code-snippets' );
145 $errors[] = sprintf( $error_message, $file_name, $snippets->get_error_message() );
146 } else {
147 $all_snippets = array_merge( $all_snippets, $snippets );
148 }
149 }
150
151 if ( empty( $all_snippets ) ) {
152 return new WP_Error(
153 'no_snippets_found',
154 __( 'No valid snippets found in the uploaded files.', 'code-snippets' ),
155 [
156 'status' => 400,
157 'errors' => $errors,
158 ],
159 );
160 }
161
162 /* translators: %d: number of snippets */
163 $message = _n(
164 'Found %d snippet ready for import.',
165 'Found %d snippets ready for import.',
166 count( $all_snippets ),
167 'code-snippets',
168 );
169
170 $response = [
171 'snippets' => $all_snippets,
172 'total_count' => count( $all_snippets ),
173 'message' => sprintf( $message, count( $all_snippets ) ),
174 ];
175
176 if ( ! empty( $errors ) ) {
177 $response['warnings'] = $errors;
178 }
179
180 return rest_ensure_response( $response );
181 }
182
183 /**
184 * Imports selected snippets into the system.
185 *
186 * @param WP_REST_Request $request The REST request.
187 *
188 * @return WP_Error|WP_REST_Response Import result or error.
189 */
190 public function import_selected_snippets( WP_REST_Request $request ) {
191 $snippets_data = $request->get_param( 'snippets' );
192 $duplicate_action = $request->get_param( 'duplicate_action' ) ?? 'ignore';
193 $network = $request->get_param( 'network' ) ?? false;
194
195 if ( empty( $snippets_data ) || ! is_array( $snippets_data ) ) {
196 return new WP_Error(
197 'no_snippets',
198 __( 'No snippet data provided for import.', 'code-snippets' ),
199 [ 'status' => 400 ]
200 );
201 }
202
203 $snippets = [];
204 foreach ( $snippets_data as $snippet_data ) {
205 $snippet = new Snippet();
206 $snippet->network = $network;
207
208 $import_fields = [
209 'name',
210 'desc',
211 'description',
212 'code',
213 'tags',
214 'scope',
215 'priority',
216 'shared_network',
217 'modified',
218 'cloud_id',
219 ];
220
221 foreach ( $import_fields as $field ) {
222 if ( isset( $snippet_data[ $field ] ) ) {
223 $snippet->set_field( $field, $snippet_data[ $field ] );
224 }
225 }
226
227 $snippets[] = $snippet;
228 }
229
230 $imported = $this->save_snippets( $snippets, $duplicate_action, $network );
231
232 /* translators: %d: number of snippets */
233 $message = _n(
234 'Successfully imported %d snippet.',
235 'Successfully imported %d snippets.',
236 count( $imported ),
237 'code-snippets',
238 );
239
240 $response = [
241 'imported' => count( $imported ),
242 'imported_ids' => $imported,
243 'message' => sprintf( $message, count( $imported ) ),
244 ];
245
246 return rest_ensure_response( $response );
247 }
248
249 /**
250 * Parses the content of a file based on its type.
251 *
252 * @param string $file_path The path to the file.
253 * @param string $extension The file extension.
254 * @param string $mime_type The MIME type of the file.
255 * @param string $file_name The original file name.
256 *
257 * @return array|WP_Error Parsed snippets or error.
258 */
259 private function parse_file_content( string $file_path, string $extension, string $mime_type, string $file_name ) {
260 if ( ! file_exists( $file_path ) || ! is_file( $file_path ) ) {
261 return new WP_Error(
262 'file_not_found',
263 __( 'File not found or is not a valid file.', 'code-snippets' )
264 );
265 }
266
267 if ( 'json' === $extension || 'application/json' === $mime_type ) {
268 return $this->parse_json_file( $file_path, $file_name );
269 } elseif ( 'xml' === $extension || in_array( $mime_type, [ 'text/xml', 'application/xml' ], true ) ) {
270 return $this->parse_xml_file( $file_path, $file_name );
271 }
272
273 return new WP_Error(
274 'unsupported_file_type',
275 __( 'Unsupported file type.', 'code-snippets' )
276 );
277 }
278
279 /**
280 * Parses a JSON file to extract snippets.
281 *
282 * @param string $file_path The path to the JSON file.
283 * @param string $file_name The original file name.
284 *
285 * @return array|WP_Error Parsed snippets or error.
286 */
287 private function parse_json_file( string $file_path, string $file_name ) {
288
289 // TODO: replace this with use of WordPress Filesystem API.
290 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
291 $raw_data = file_get_contents( $file_path );
292
293 $data = json_decode( $raw_data, true );
294
295 if ( json_last_error() !== JSON_ERROR_NONE ) {
296 return new WP_Error(
297 'invalid_json',
298 sprintf(
299 /* translators: %1$s: file name, %2$s: error message */
300 __( 'Invalid JSON in file %1$s: %2$s', 'code-snippets' ),
301 $file_name,
302 json_last_error_msg()
303 )
304 );
305 }
306
307 if ( ! isset( $data['snippets'] ) || ! is_array( $data['snippets'] ) ) {
308 /* translators: %s: file name */
309 $message = __( 'No snippets found in file %s', 'code-snippets' );
310 return new WP_Error( 'no_snippets_in_file', sprintf( $message, $file_name ) );
311 }
312
313 $snippets = [];
314 foreach ( $data['snippets'] as $snippet_data ) {
315 $snippet_data['source_file'] = $file_name;
316
317 $snippet_data['table_data'] = [
318 'id' => $snippet_data['id'] ?? uniqid(),
319 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ),
320 'scope' => $snippet_data['scope'] ?? 'global',
321 'tags' => is_array( $snippet_data['tags'] ?? [] ) ? implode( ', ', $snippet_data['tags'] ) : '',
322 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '',
323 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ),
324 ];
325
326 $snippets[] = $snippet_data;
327 }
328
329 return $snippets;
330 }
331
332 /**
333 * Parse snippets from XML file for importing.
334 *
335 * @param string $file_path Path to file.
336 * @param string $file_name Name of file.
337 *
338 * @return array|WP_Error
339 *
340 * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
341 */
342 private function parse_xml_file( string $file_path, string $file_name ) {
343 $dom = new DOMDocument( '1.0', get_bloginfo( 'charset' ) );
344
345 if ( ! $dom->load( $file_path ) ) {
346 /* translators: %s: file name */
347 $message = __( 'Invalid XML in file %s', 'code-snippets' );
348 return new WP_Error( 'invalid_xml', sprintf( $message, $file_name ) );
349 }
350
351 $snippets_xml = $dom->getElementsByTagName( 'snippet' );
352 $fields = [ 'name', 'description', 'desc', 'code', 'tags', 'scope' ];
353
354 $snippets = [];
355 $index = 0;
356
357 foreach ( $snippets_xml as $snippet_xml ) {
358 $snippet_data = [];
359
360 foreach ( $fields as $field_name ) {
361 $field = $snippet_xml->getElementsByTagName( $field_name )->item( 0 );
362
363 if ( isset( $field->nodeValue ) ) {
364 $snippet_data[ $field_name ] = $field->nodeValue;
365 }
366 }
367
368 $scope = $snippet_xml->getAttribute( 'scope' );
369 if ( ! empty( $scope ) ) {
370 $snippet_data['scope'] = $scope;
371 }
372
373 $snippet_data['source_file'] = $file_name;
374
375 $snippet_data['table_data'] = [
376 'id' => ++$index,
377 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ),
378 'scope' => $snippet_data['scope'] ?? 'global',
379 'tags' => $snippet_data['tags'] ?? '',
380 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '',
381 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ),
382 ];
383
384 $snippets[] = $snippet_data;
385 }
386
387 return $snippets;
388 }
389
390 /**
391 * Saves snippets to the database, handling duplicates based on the specified action.
392 *
393 * @param array $snippets Array of Snippet objects to save.
394 * @param string $duplicate_action Action to take on duplicates: 'ignore', 'replace', or 'skip'.
395 * @param bool $network Whether to save to the network table.
396 *
397 * @return array Array of imported snippet IDs.
398 */
399 private function save_snippets( array $snippets, string $duplicate_action, bool $network ): array {
400 $existing_snippets = [];
401
402 if ( 'replace' === $duplicate_action || 'skip' === $duplicate_action ) {
403 $all_snippets = get_snippets( [], $network );
404
405 foreach ( $all_snippets as $snippet ) {
406 if ( $snippet->name ) {
407 $existing_snippets[ $snippet->name ] = $snippet->id;
408 }
409 }
410 }
411
412 $imported = [];
413
414 foreach ( $snippets as $snippet ) {
415 if ( 'ignore' !== $duplicate_action && isset( $existing_snippets[ $snippet->name ] ) ) {
416 if ( 'replace' === $duplicate_action ) {
417 $snippet->id = $existing_snippets[ $snippet->name ];
418 } elseif ( 'skip' === $duplicate_action ) {
419 continue;
420 }
421 }
422
423 $saved_snippet = save_snippet( $snippet );
424
425 $snippet_id = $saved_snippet->id;
426
427 if ( $snippet_id ) {
428 $imported[] = $snippet_id;
429 }
430 }
431
432 return $imported;
433 }
434
435 /**
436 * Determines if the file type is valid for import.
437 *
438 * @param string $extension File extension, without leading dot.
439 * @param string $mime_type MIME type of the file.
440 *
441 * @return bool
442 */
443 private function is_valid_file_type( string $extension, string $mime_type ): bool {
444 $valid_extensions = [ 'json', 'xml' ];
445 $valid_mime_types = [ 'application/json', 'text/xml', 'application/xml' ];
446
447 return in_array( $extension, $valid_extensions, true ) ||
448 in_array( $mime_type, $valid_mime_types, true );
449 }
450
451
452 /**
453 * Convert upload error code into a human-readable message.
454 *
455 * @param int $error_code Error code.
456 *
457 * @return string Translated error message.
458 */
459 private function get_upload_error_message( int $error_code ): string {
460 $error_messages = [
461 UPLOAD_ERR_INI_SIZE => __( 'File exceeds the upload_max_filesize directive.', 'code-snippets' ),
462 UPLOAD_ERR_FORM_SIZE => __( 'File exceeds the MAX_FILE_SIZE directive.', 'code-snippets' ),
463 UPLOAD_ERR_PARTIAL => __( 'File was only partially uploaded.', 'code-snippets' ),
464 UPLOAD_ERR_NO_FILE => __( 'No file was uploaded.', 'code-snippets' ),
465 UPLOAD_ERR_NO_TMP_DIR => __( 'Missing a temporary folder.', 'code-snippets' ),
466 UPLOAD_ERR_CANT_WRITE => __( 'Failed to write file to disk.', 'code-snippets' ),
467 UPLOAD_ERR_EXTENSION => __( 'A PHP extension stopped the file upload.', 'code-snippets' ),
468 ];
469
470 return $error_messages[ $error_code ] ?? __( 'Unknown upload error.', 'code-snippets' );
471 }
472 }
473