PluginProbe
Code Snippets / 3.10.0
Code Snippets v3.10.0
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 3.0.1 All 64 releases
code-snippets / php / REST_API / Import / File_Import_REST_Controller.php

File_Import_REST_Controller.php in Code Snippets 3.10.0, at php/REST_API/Import/File_Import_REST_Controller.php

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