PluginProbe
Cooked – Recipe Management / 1.16.1
Cooked – Recipe Management v1.16.1
1.16.1 1.16.0 1.15.0 trunk 1.10.0 1.11.0 1.11.1 1.11.2 1.11.3 1.11.4 1.12.0 1.13.0 1.14.0 1.7.10 1.7.11 1.7.12 1.7.13 1.7.15.1 1.7.15.3 1.7.15.4 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 All 37 releases
cooked / includes / class.cooked-csv-import.php

class.cooked-csv-import.php in Cooked – Recipe Management 1.16.1, at includes/class.cooked-csv-import.php

652 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CSV Import Handler
4 *
5 * @package Cooked
6 * @subpackage CSV Import
7 * @since 1.13.0
8 */
9
10 /**
11 * Exit if accessed directly
12 */
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Cooked_CSV_Import Class
19 *
20 * This class handles the import of recipes from CSV files.
21 *
22 * @since 1.13.0
23 */
24 class Cooked_CSV_Import {
25
26 /**
27 * Parse and import recipes from CSV file
28 *
29 * @param string $file_path Path to the CSV file.
30 * @return array Results array with success count and errors
31 */
32 public static function import_from_file( $file_path ) {
33 global $_cooked_settings;
34
35 $results = [
36 'success' => 0,
37 'errors' => [],
38 'total' => 0,
39 ];
40
41 if ( ! file_exists( $file_path ) ) {
42 $results['errors'][] = __( 'CSV file not found.', 'cooked' );
43 return $results;
44 }
45
46 /**
47 * Open and parse CSV file
48 */
49 $handle = fopen( $file_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV row streaming.
50 if ( false === $handle ) {
51 $results['errors'][] = __( 'Could not open CSV file.', 'cooked' );
52 return $results;
53 }
54
55 /**
56 * Read header row
57 */
58 $headers = fgetcsv( $handle );
59 if ( false === $headers || empty( $headers ) ) {
60 $results['errors'][] = __( 'CSV file is empty or invalid.', 'cooked' );
61 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- CSV row streaming.
62 return $results;
63 }
64
65 /**
66 * Normalize headers (trim and lowercase)
67 */
68 $headers = array_map( 'trim', $headers );
69 $headers = array_map( 'strtolower', $headers );
70
71 /**
72 * Check for required title column
73 */
74 if ( ! in_array( 'title', $headers ) ) {
75 $results['errors'][] = __( 'CSV file must contain a "title" column.', 'cooked' );
76 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- CSV row streaming.
77 return $results;
78 }
79
80 $row_number = 1;
81 while ( ( $row = fgetcsv( $handle ) ) !== false ) {
82 ++$row_number;
83 ++$results['total'];
84
85 /**
86 * Skip empty rows
87 */
88 if ( empty( array_filter( $row ) ) ) {
89 continue;
90 }
91
92 /**
93 * Map row data to headers
94 */
95 $data = [];
96 foreach ( $headers as $index => $header ) {
97 $data[ $header ] = isset( $row[ $index ] ) ? trim( $row[ $index ] ) : '';
98 }
99
100 /**
101 * Import this recipe
102 */
103 try {
104 $import_result = self::import_recipe( $data, $row_number );
105 if ( $import_result['success'] ) {
106 ++$results['success'];
107 } else {
108 $error_msg = isset( $import_result['error'] ) ? $import_result['error'] : sprintf(
109 /* translators: %d: row number */
110 __( 'Row %1$d: Unknown error', 'cooked' ),
111 $row_number
112 );
113 $results['errors'][] = $error_msg;
114
115 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
116 // phpcs:disable
117 error_log( 'Cooked CSV Import Error Row ' . $row_number . ': ' . $error_msg );
118 // phpcs:enable
119 }
120 }
121 } catch ( Exception $e ) {
122 $error_msg = $e->getMessage();
123 /* translators: 1: CSV row number, 2: error message */
124 $results['errors'][] = sprintf( __( 'Row %1$d: %2$s', 'cooked' ), $row_number, $error_msg );
125
126 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
127 // phpcs:disable
128 error_log( 'Cooked CSV Import Exception Row ' . $row_number . ': ' . $error_msg );
129 error_log( 'Stack trace: ' . $e->getTraceAsString() );
130 // phpcs:enable
131 }
132 }
133 }
134
135 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- CSV row streaming.
136 return $results;
137 }
138
139 /**
140 * Import a single recipe from CSV data
141 *
142 * @param array $data Recipe data from CSV row.
143 * @param int $row_number Row number for error reporting.
144 * @return array Result with success status and error message if any
145 */
146 public static function import_recipe( $data, $row_number = 0 ) {
147 global $_cooked_settings;
148
149 /**
150 * Validate required fields
151 */
152 if ( empty( $data['title'] ) ) {
153 return [
154 'success' => false,
155 'error' => sprintf(
156 /* translators: %d: row number */
157 __( 'Row %1$d: Title is required', 'cooked' ),
158 $row_number
159 ),
160 ];
161 }
162
163 /**
164 * Get default content
165 */
166 if ( isset( $_cooked_settings['default_content'] ) ) {
167 $default_content = stripslashes( $_cooked_settings['default_content'] );
168 } else {
169 $default_content = Cooked_Recipes::default_content();
170 }
171
172 /**
173 * Create new recipe post
174 */
175 $new_recipe = [
176 'post_type' => 'cp_recipe',
177 'post_status' => 'draft',
178 'post_title' => sanitize_text_field( $data['title'] ),
179 'post_content' => '',
180 'post_author' => get_current_user_id(),
181 ];
182
183 $recipe_id = wp_insert_post( $new_recipe );
184 if ( is_wp_error( $recipe_id ) ) {
185 return [
186 'success' => false,
187 'error' => sprintf(
188 /* translators: 1: row number, 2: error message */
189 __( 'Row %1$d: %2$s', 'cooked' ),
190 $row_number,
191 $recipe_id->get_error_message()
192 ),
193 ];
194 }
195
196 /**
197 * Prepare recipe meta
198 */
199 $recipe_meta = [];
200 $recipe_meta['cooked_version'] = COOKED_VERSION;
201 $recipe_meta['content'] = $default_content;
202 $recipe_meta['excerpt'] = isset( $data['excerpt'] ) ? sanitize_text_field( $data['excerpt'] ) : '';
203 $recipe_meta['seo_description'] = isset( $data['seo_description'] ) ? sanitize_text_field( $data['seo_description'] ) : ( isset( $data['excerpt'] ) ? sanitize_text_field( $data['excerpt'] ) : '' );
204 $recipe_meta['notes'] = isset( $data['notes'] ) ? wp_kses_post( $data['notes'] ) : '';
205
206 /**
207 * Difficulty level
208 */
209 $difficulty_level = isset( $data['difficulty_level'] ) ? intval( $data['difficulty_level'] ) : 0;
210 if ( $difficulty_level < 1 || $difficulty_level > 3 ) {
211 $difficulty_level = 0;
212 }
213 $recipe_meta['difficulty_level'] = $difficulty_level;
214
215 /**
216 * Times
217 */
218 $recipe_meta['prep_time'] = isset( $data['prep_time'] ) ? intval( $data['prep_time'] ) : 0;
219 $recipe_meta['cook_time'] = isset( $data['cook_time'] ) ? intval( $data['cook_time'] ) : 0;
220 $recipe_meta['total_time'] = $recipe_meta['prep_time'] + $recipe_meta['cook_time'];
221 if ( isset( $data['total_time'] ) && ! empty( $data['total_time'] ) ) {
222 $recipe_meta['total_time'] = intval( $data['total_time'] );
223 }
224
225 /**
226 * Parse ingredients
227 */
228 $recipe_meta['ingredients'] = [];
229 if ( ! empty( $data['ingredients'] ) ) {
230 $measurements = Cooked_Measurements::get();
231
232 /** Split by | to get all parts
233 * Format: amount|measurement|name|amount|measurement|name||sub_amount|sub_measurement|sub_name|...
234 * When we see ||, it becomes two consecutive empty strings in the array
235 */
236 $all_parts = array_map( 'trim', explode( '|', $data['ingredients'] ) );
237 $i = 0;
238
239 while ( $i < count( $all_parts ) ) {
240 /**
241 * Skip empty parts (they come from || separator)
242 */
243 if ( empty( $all_parts[ $i ] ) ) {
244 ++$i;
245 continue;
246 }
247
248 $part = $all_parts[ $i ];
249
250 /**
251 * Check if it's a section heading (starts with #)
252 */
253 if ( strpos( $part, '#' ) === 0 ) {
254 $recipe_meta['ingredients'][] = [
255 'section_heading_name' => trim( $part, '#' ),
256 ];
257 ++$i;
258 continue;
259 }
260
261 /**
262 * Collect next 3 non-empty parts for an ingredient (amount|measurement|name)
263 */
264 $ingredient_parts = [];
265 $j = $i;
266 while ( count( $ingredient_parts ) < 3 && $j < count( $all_parts ) ) {
267 $p = trim( $all_parts[ $j ] );
268 if ( ! empty( $p ) ) {
269 $ingredient_parts[] = $p;
270 }
271 ++$j;
272 }
273
274 if ( count( $ingredient_parts ) >= 3 ) {
275 $ingredient = self::parse_ingredient_parts( $ingredient_parts, $measurements );
276 /**
277 * Move past the collected parts
278 */
279 $i = $j;
280
281 /**
282 * Check if next parts are empty (indicating || separator for substitution)
283 *
284 * Look ahead to see if we have empty parts followed by non-empty parts
285 */
286 $next_empty_count = 0;
287 $k = $i;
288 while ( $k < count( $all_parts ) && empty( trim( $all_parts[ $k ] ) ) ) {
289 ++$next_empty_count;
290 ++$k;
291 }
292
293 /**
294 * If we have empty parts (from ||) and then more parts, it's a substitution
295 */
296 if ( $next_empty_count > 0 && $k < count( $all_parts ) ) {
297 /**
298 * Collect substitution parts (next 3 non-empty parts)
299 */
300 $sub_parts = [];
301 $sub_i = $k;
302 while ( count( $sub_parts ) < 3 && $sub_i < count( $all_parts ) ) {
303 $sub_part = trim( $all_parts[ $sub_i ] );
304 if ( ! empty( $sub_part ) ) {
305 $sub_parts[] = $sub_part;
306 }
307 ++$sub_i;
308 }
309
310 /**
311 * Parse substitution
312 */
313 if ( count( $sub_parts ) >= 3 ) {
314 $ingredient['sub_amount'] = sanitize_text_field( $sub_parts[0] );
315 $sub_measurement = sanitize_text_field( $sub_parts[1] );
316 $matched_sub_measurement = self::match_measurement( $sub_measurement, $measurements );
317 if ( $matched_sub_measurement ) {
318 $ingredient['sub_measurement'] = $matched_sub_measurement;
319 }
320 $ingredient['sub_name'] = sanitize_text_field( $sub_parts[2] );
321 } elseif ( count( $sub_parts ) === 2 ) {
322 $ingredient['sub_amount'] = sanitize_text_field( $sub_parts[0] );
323 $ingredient['sub_name'] = sanitize_text_field( $sub_parts[1] );
324 } elseif ( count( $sub_parts ) === 1 ) {
325 $ingredient['sub_name'] = sanitize_text_field( $sub_parts[0] );
326 }
327
328 /**
329 * Move past substitution
330 */
331 $i = $sub_i;
332 }
333
334 $recipe_meta['ingredients'][] = $ingredient;
335 } else {
336 /**
337 * Not enough parts for a complete ingredient, skip
338 */
339 ++$i;
340 }
341 }
342 }
343
344 /**
345 * Parse directions
346 */
347 $recipe_meta['directions'] = [];
348 if ( ! empty( $data['directions'] ) ) {
349 $directions = explode( '|', $data['directions'] );
350 foreach ( $directions as $direction_string ) {
351 $direction_string = trim( $direction_string );
352 if ( empty( $direction_string ) ) {
353 continue;
354 }
355
356 /**
357 * Check if it's a section heading (starts with #)
358 */
359 if ( strpos( $direction_string, '#' ) === 0 ) {
360 $recipe_meta['directions'][] = [
361 'section_heading_name' => trim( $direction_string, '#' ),
362 ];
363 continue;
364 }
365
366 $recipe_meta['directions'][] = [
367 'content' => wp_kses_post( $direction_string ),
368 ];
369 }
370 }
371
372 /**
373 * Nutrition data
374 */
375 $recipe_meta['nutrition'] = [];
376 if ( isset( $data['servings'] ) && ! empty( $data['servings'] ) ) {
377 $recipe_meta['nutrition']['servings'] = sanitize_text_field( $data['servings'] );
378 }
379 if ( isset( $data['calories'] ) && ! empty( $data['calories'] ) ) {
380 $recipe_meta['nutrition']['calories'] = intval( $data['calories'] );
381 }
382
383 /**
384 * Save recipe meta
385 */
386 $recipe_meta = Cooked_Recipe_Meta::meta_cleanup( $recipe_meta );
387 update_post_meta( $recipe_id, '_recipe_settings', $recipe_meta );
388
389 /**
390 * Update post excerpt
391 */
392 $recipe_excerpt = ! empty( $recipe_meta['excerpt'] ) ? $recipe_meta['excerpt'] : get_the_title( $recipe_id );
393 $seo_content = apply_filters( 'cooked_seo_recipe_content', '<h2>' . wp_kses_post( $recipe_excerpt ) . '</h2><h3>' . __( 'Ingredients', 'cooked' ) . '</h3>[cooked-ingredients checkboxes=false]<h3>' . __( 'Directions', 'cooked' ) . '</h3>[cooked-directions numbers=false]' );
394 $seo_content = do_shortcode( $seo_content );
395
396 $should_update_content = apply_filters( 'cooked_should_update_post_content', true, $recipe_id );
397 if ( $should_update_content ) {
398 wp_update_post(
399 [
400 'ID' => $recipe_id,
401 'post_excerpt' => $recipe_excerpt,
402 'post_content' => $seo_content,
403 ]
404 );
405 } else {
406 wp_update_post(
407 [
408 'ID' => $recipe_id,
409 'post_excerpt' => $recipe_excerpt,
410 ]
411 );
412 }
413
414 /**
415 * Handle taxonomies
416 */
417 if ( ! empty( $data['categories'] ) && taxonomy_exists( 'cp_recipe_category' ) ) {
418 $categories = array_map( 'trim', explode( ',', $data['categories'] ) );
419 $category_ids = [];
420 foreach ( $categories as $category_name ) {
421 $category_name = sanitize_text_field( $category_name );
422 if ( ! empty( $category_name ) ) {
423 $term = get_term_by( 'name', $category_name, 'cp_recipe_category' );
424 if ( ! $term ) {
425 $term = wp_insert_term( $category_name, 'cp_recipe_category' );
426 if ( ! is_wp_error( $term ) ) {
427 $category_ids[] = $term['term_id'];
428 }
429 } else {
430 $category_ids[] = $term->term_id;
431 }
432 }
433 }
434 if ( ! empty( $category_ids ) ) {
435 wp_set_object_terms( $recipe_id, $category_ids, 'cp_recipe_category' );
436 }
437 }
438
439 if ( defined( 'COOKED_PRO_VERSION' ) ) {
440 if ( ! empty( $data['cuisine'] ) && taxonomy_exists( 'cp_recipe_cuisine' ) ) {
441 $cuisines = array_map( 'trim', explode( ',', $data['cuisine'] ) );
442 $cuisine_ids = [];
443 foreach ( $cuisines as $cuisine_name ) {
444 $cuisine_name = sanitize_text_field( $cuisine_name );
445 if ( ! empty( $cuisine_name ) ) {
446 $term = get_term_by( 'name', $cuisine_name, 'cp_recipe_cuisine' );
447 if ( ! $term ) {
448 $term = wp_insert_term( $cuisine_name, 'cp_recipe_cuisine' );
449 if ( ! is_wp_error( $term ) ) {
450 $cuisine_ids[] = $term['term_id'];
451 }
452 } else {
453 $cuisine_ids[] = $term->term_id;
454 }
455 }
456 }
457 if ( ! empty( $cuisine_ids ) ) {
458 wp_set_object_terms( $recipe_id, $cuisine_ids, 'cp_recipe_cuisine' );
459 }
460 }
461
462 if ( ! empty( $data['cooking_method'] ) && taxonomy_exists( 'cp_recipe_cooking_method' ) ) {
463 $cooking_methods = array_map( 'trim', explode( ',', $data['cooking_method'] ) );
464 $cooking_method_ids = [];
465 foreach ( $cooking_methods as $cooking_method_name ) {
466 $cooking_method_name = sanitize_text_field( $cooking_method_name );
467 if ( ! empty( $cooking_method_name ) ) {
468 $term = get_term_by( 'name', $cooking_method_name, 'cp_recipe_cooking_method' );
469 if ( ! $term ) {
470 $term = wp_insert_term( $cooking_method_name, 'cp_recipe_cooking_method' );
471 if ( ! is_wp_error( $term ) ) {
472 $cooking_method_ids[] = $term['term_id'];
473 }
474 } else {
475 $cooking_method_ids[] = $term->term_id;
476 }
477 }
478 }
479 if ( ! empty( $cooking_method_ids ) ) {
480 wp_set_object_terms( $recipe_id, $cooking_method_ids, 'cp_recipe_cooking_method' );
481 }
482 }
483
484 if ( ! empty( $data['diet'] ) && taxonomy_exists( 'cp_recipe_diet' ) ) {
485 $diets = array_map( 'trim', explode( ',', $data['diet'] ) );
486 $diet_ids = [];
487 foreach ( $diets as $diet_name ) {
488 $diet_name = sanitize_text_field( $diet_name );
489 if ( empty( $diet_name ) ) {
490 continue;
491 }
492 /**
493 * The cp_recipe_diet taxonomy is restricted to Schema.org RestrictedDiet values - only assign existing terms
494 */
495 $term = get_term_by( 'name', $diet_name, 'cp_recipe_diet' );
496
497 if ( $term ) {
498 $diet_ids[] = $term->term_id;
499 }
500 }
501 if ( ! empty( $diet_ids ) ) {
502 wp_set_object_terms( $recipe_id, $diet_ids, 'cp_recipe_diet' );
503 }
504 }
505
506 if ( ! empty( $data['tags'] ) && taxonomy_exists( 'cp_recipe_tags' ) ) {
507 $tags = array_map( 'trim', explode( ',', $data['tags'] ) );
508 $tag_ids = [];
509 foreach ( $tags as $tag_name ) {
510 if ( ! empty( $tag_name ) ) {
511 $term = get_term_by( 'name', $tag_name, 'cp_recipe_tags' );
512 if ( ! $term ) {
513 $term = wp_insert_term( $tag_name, 'cp_recipe_tags' );
514 if ( ! is_wp_error( $term ) ) {
515 $tag_ids[] = $term['term_id'];
516 }
517 } else {
518 $tag_ids[] = $term->term_id;
519 }
520 }
521 }
522 if ( ! empty( $tag_ids ) ) {
523 wp_set_object_terms( $recipe_id, $tag_ids, 'cp_recipe_tags' );
524 }
525 }
526 }
527
528 return [
529 'success' => true,
530 'recipe_id' => $recipe_id,
531 ];
532 }
533
534 /**
535 * Match a measurement string to a measurement key.
536 * Checks exact key match, variations, singular, and plural forms.
537 *
538 * @param string $measurement_string The measurement string from CSV.
539 * @param array $measurements Full measurements array.
540 * @return string|false The measurement key or false if not found
541 */
542 private static function match_measurement( $measurement_string, $measurements ) {
543 $measurement_string = strtolower( trim( $measurement_string ) );
544
545 /**
546 * First, check for exact key match
547 */
548 if ( isset( $measurements[ $measurement_string ] ) ) {
549 return $measurement_string;
550 }
551
552 /**
553 * Check variations, singular, and plural for each measurement
554 */
555 foreach ( $measurements as $key => $measurement_data ) {
556 /**
557 * Check variations
558 */
559 if ( isset( $measurement_data['variations'] ) && is_array( $measurement_data['variations'] ) ) {
560 foreach ( $measurement_data['variations'] as $variation ) {
561 if ( strtolower( $variation ) === $measurement_string ) {
562 return $key;
563 }
564 }
565 }
566
567 /**
568 * Check singular
569 */
570 if ( isset( $measurement_data['singular'] ) && strtolower( $measurement_data['singular'] ) === $measurement_string ) {
571 return $key;
572 }
573
574 /**
575 * Check plural
576 */
577 if ( isset( $measurement_data['plural'] ) && strtolower( $measurement_data['plural'] ) === $measurement_string ) {
578 return $key;
579 }
580
581 /**
582 * Check singular abbreviation
583 */
584 if ( isset( $measurement_data['singular_abbr'] ) && strtolower( $measurement_data['singular_abbr'] ) === $measurement_string ) {
585 return $key;
586 }
587
588 /**
589 * Check plural abbreviation
590 */
591 if ( isset( $measurement_data['plural_abbr'] ) && strtolower( $measurement_data['plural_abbr'] ) === $measurement_string ) {
592 return $key;
593 }
594 }
595
596 return false;
597 }
598
599 /**
600 * Parse ingredient parts into ingredient array
601 *
602 * @param array $parts Array of ingredient parts (amount, measurement, name, etc.).
603 * @param array $measurements Full measurements array.
604 * @return array|false Ingredient array or false on error
605 */
606 private static function parse_ingredient_parts( $parts, $measurements ) {
607 if ( empty( $parts ) ) {
608 return false;
609 }
610
611 $ingredient = [
612 'amount' => '',
613 'measurement' => '',
614 'name' => '',
615 'url' => '',
616 'description' => '',
617 'sub_amount' => '',
618 'sub_measurement' => '',
619 'sub_name' => '',
620 ];
621
622 if ( count( $parts ) >= 3 ) {
623 /**
624 * Format: amount|measurement|name
625 */
626 $ingredient['amount'] = sanitize_text_field( $parts[0] );
627 $measurement = sanitize_text_field( $parts[1] );
628 $matched_measurement = self::match_measurement( $measurement, $measurements );
629 if ( $matched_measurement ) {
630 $ingredient['measurement'] = $matched_measurement;
631 }
632 $ingredient['name'] = sanitize_text_field( $parts[2] );
633 if ( isset( $parts[3] ) ) {
634 $ingredient['description'] = sanitize_text_field( $parts[3] );
635 }
636 } elseif ( count( $parts ) === 2 ) {
637 /**
638 * Format: amount|name (no measurement)
639 */
640 $ingredient['amount'] = sanitize_text_field( $parts[0] );
641 $ingredient['name'] = sanitize_text_field( $parts[1] );
642 } else {
643 /**
644 * Format: name only
645 */
646 $ingredient['name'] = sanitize_text_field( $parts[0] );
647 }
648
649 return $ingredient;
650 }
651 }
652