PluginProbe
Cooked – Recipe Management / trunk
Cooked – Recipe Management vtrunk
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 1.8.5 All 36 releases
cooked / includes / class.cooked-csv-import.php

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

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