oont-contents/plugins/woocommerce-customer-order-csv-import/src/class-wc-csv-import-suite-importer.php
2025-02-08 15:10:23 +01:00

1773 lines
47 KiB
PHP

<?php
/**
* WooCommerce Customer/Order/Coupon CSV Import Suite
*
* This source file is subject to the GNU General Public License v3.0
* that is bundled with this package in the file license.txt.
* It is also available through the world-wide-web at this URL:
* http://www.gnu.org/licenses/gpl-3.0.html
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@skyverge.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade WooCommerce Customer/Order/Coupon CSV Import Suite to newer
* versions in the future. If you wish to customize WooCommerce Customer/Order/Coupon CSV Import Suite for your
* needs please refer to http://docs.woocommerce.com/document/customer-order-csv-import-suite/ for more information.
*
* @author SkyVerge
* @copyright Copyright (c) 2012-2023, SkyVerge, Inc.
* @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0
*/
use SkyVerge\WooCommerce\PluginFramework\v5_11_3 as Framework;
if ( ! class_exists( 'WP_Importer' ) ) return;
defined( 'ABSPATH' ) or exit;
/**
* WooCommerce CSV Import Suite base Importer class
* for managing the import process of a CSV file.
*
* All concrete importers must subclass this.
*
* @since 3.0.0
*/
class WC_CSV_Import_Suite_Importer extends \WP_Importer {
/** @var string importer title */
protected $title;
/** @var string CSV delimiter */
protected $delimiter = ',';
/** @var string file being imported */
protected $file;
/** @var array import results */
protected $results = array();
/** @var bool has this importer been dispatched? */
protected $has_dispatched = false;
/** @var array valid delimiters */
private $valid_delimiters;
/** @var int current CSV line number **/
protected $line_num;
/** @var array Import progress **/
protected $import_progress = array();
/** @var array Import results **/
protected $import_results = array();
/** @var array Taxonomy terms created during import */
private $inserted_terms = array();
/** @var array Translatable strings for the UI, similar to post type labels **/
protected $i18n = array();
/** @var int Greeting and import source options form */
const GREETING_AND_IMPORT_SOURCE_STEP = 0;
/** @var int Display file upload / url / copy-paste input form */
const DISPLAY_FILE_UPLOAD_STEP = 1;
/** @var int Detect delimiter and render additional options & preview */
const DETECT_DELIMITER_AND_RENDER_STEP = 2;
/** @var int Display column mapper */
const DISPLAY_COLUMN_MAPPER_STEP = 3;
/** @var int Display column mapper */
const RUN_IMPORT_STEP = 4;
/**
* Constructor
*
* @since 3.0.0
*/
public function __construct() {
parent::__construct();
$this->i18n = [
'count' => esc_html__( '%s items' ),
'count_inserted' => esc_html__( '%s items inserted' ),
'count_merged' => esc_html__( '%s items merged' ),
'count_skipped' => esc_html__( '%s items skipped' ),
'count_failed' => esc_html__( '%s items failed' ),
];
}
/**
* Manages the separate stages of the CSV import process
*
* This method may be called either before any output is sent to the buffer
* or when some output has already been sent. The first case should only
* be used for handling POST requests in the CSV import process, and visitor
* should be redirected to a idempotent page after processing the request.
*
* The importer class uses the 'redirect after post' pattern - and all
* subclasses must also follow the same pattern.
* This is to make sure reloading a screen during import does not alter the
* import progress in any way.
*
* 1. Display introductory text and source select
* 2. Handle the physical upload/sideload of the source
* 3. Detect delimiter, display preview and import options
* 4. Display column mapper
* 5. Kick-off parsing & importing from the input source
*
* @since 3.0.0
*/
public function dispatch(): void {
// prevent dispatching more than once
if ( $this->has_dispatched ) {
return;
}
$this->has_dispatched = true;
$step = isset( $_GET['step'] ) ? (int) $_GET['step'] : self::GREETING_AND_IMPORT_SOURCE_STEP;
$type = isset( $_GET['import'] ) ? sanitize_key( $_GET['import'] ) : null;
$source = isset( $_GET['source'] ) ? sanitize_key( $_GET['source'] ) : null;
$action = isset( $_REQUEST['action'] ) ? trim( $_REQUEST['action'] ) : null;
$file = isset( $_REQUEST['file'] ) ? trim( $_REQUEST['file'] ) : null;
$job_id = isset( $_REQUEST['job_id'] ) ? trim( $_REQUEST['job_id'] ) : null;
if ( ! $action ) {
$this->header();
}
// non-idempotent steps - action/POST request handlers
if ( $action ) {
switch ( $action ) {
// handle source upload/sideload
case 'upload':
check_admin_referer( 'import-upload' );
$uploaded_file = $this->handle_upload( $type, $source );
$file_name = basename( $uploaded_file );
if ( $file_name ) {
$redirect_to = add_query_arg( [
'import' => $type,
'step' => self::DETECT_DELIMITER_AND_RENDER_STEP,
'file' => urlencode( $file_name ),
'source' => $source,
'next_step' => self::DISPLAY_COLUMN_MAPPER_STEP,
], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect_to );
return;
}
break;
// kick-off parsing & import
case 'kickoff':
check_admin_referer( 'import-woocommerce' );
if ( $file ) {
$file_path = $this->get_uploaded_csv_file_path( $file );
$size = filesize( $file_path );
/**
* Filter CSV import options
*
* @since 3.0.0
* @param array $options
* @param string $file_path Path to CSV file
* @param string $type Import type
*/
$options = apply_filters( 'wc_csv_import_suite_import_options', (array) $_REQUEST['options'], $file_path, $type );
// Setting default options will ensure that these options are set
// even if they're unchecked
$default_options = [
'merge' => false,
'dry_run' => false,
'insert_non_matching' => false,
'debug_mode' => false,
];
$options = wp_parse_args( $options, $default_options );
// add logging if it's been enabled for this import
if ( $options['debug_mode'] ) {
update_option( 'wc_csv_import_suite_debug_mode', 'yes' );
}
$job_attrs = [
'type' => $type,
'file_path' => $file_path,
'file_size' => $size,
'options' => $options,
];
$this->start_background_import( $job_attrs );
}
break;
case 'run_live':
check_admin_referer( 'import-woocommerce' );
if ( $job_id ) {
$results = get_option( 'wc_csv_import_suite_background_import_job_' . $job_id );
if ( $results ) {
$job = json_decode( $results, true );
if ( 'completed' === $job['status'] ) {
$options = $job['options'];
$options['dry_run'] = false;
$job_attrs = $job;
$job_attrs['options'] = $options;
}
$this->start_background_import( $job_attrs ?? [] );
}
}
break;
}
}
// idempotent steps
else {
switch ( $step ) {
case self::GREETING_AND_IMPORT_SOURCE_STEP :
// render job import progress
if ( $job_id ) {
$this->render_import_progress( $_GET['job_id'] );
}
else {
$this->render_import_source_options();
}
break;
case self::DISPLAY_FILE_UPLOAD_STEP :
$this->render_source_input_form( $type, $source );
break;
default :
$file_path = $this->get_uploaded_csv_file_path( $file );
if ( is_null( $file_path ) ) {
$redirect_to = add_query_arg( [
'import' => $type,
'step' => self::GREETING_AND_IMPORT_SOURCE_STEP,
], admin_url( 'admin.php' ) );
wp_safe_redirect( $redirect_to );
return;
}
if ( self::DETECT_DELIMITER_AND_RENDER_STEP === $step ) {
// sanity check - does the file exist?
$this->ensure_file_is_readable( $file_path );
$sample = \WC_CSV_Import_Suite_Parser::get_sample( $file_path );
$delimiter = $this->guess_delimiter( $sample );
[ $data, $headers ] = \WC_CSV_Import_Suite_Parser::parse_sample_data( $file_path, $delimiter );
$data = [ 1 => $headers ] + $data;
$this->render_import_options( $data, $delimiter );
}
if ( self::DISPLAY_COLUMN_MAPPER_STEP === $step ) {
// sanity check - does the file exist?
$this->ensure_file_is_readable( $file_path );
$options = (array) $_REQUEST['options'];
[ $data, $raw_headers ] = \WC_CSV_Import_Suite_Parser::parse_sample_data( $file_path, $options['delimiter'], 3 );
$this->render_column_mapper( $data, $options, $raw_headers );
}
}
}
if ( ! $action ) {
$this->footer();
}
}
/**
* Kick off background import for the provided importer
*
* Will redirect the browser to the import progress screen
*
* @since 3.0.0
* @param array $attrs Job attrs for WC_CSV_Import_Suite_Background_Import
*/
private function start_background_import( array $attrs ): void {
// sanity check - does the file exist? the file may be removed between
// dry & live run
$this->ensure_file_is_readable( $attrs['file_path'] );
$background_jobs = wc_csv_import_suite()->get_background_import_instance();
$job = $background_jobs->create_job( $attrs );
$background_jobs->dispatch();
$redirect_to = admin_url( 'admin.php?import=' . $attrs['type'] . '&job_id=' . urlencode( $job->id ) );
wp_safe_redirect( $redirect_to );
exit;
}
/**
* Display import page title
*
* @since 3.0.0
*/
protected function header(): void {
echo '<div class="wrap"><div class="icon32" id="icon-woocommerce-importer"><br></div>';
echo '<h2>' . $this->get_title() . '</h2>';
wc_csv_import_suite()->get_message_handler()->load_messages();
wc_csv_import_suite()->get_message_handler()->show_messages();
}
/**
* Close div.wrap
*
* @since 3.0.0
*/
protected function footer(): void {
echo '<script type="text/javascript">jQuery( ".importer_loader, .progress" ).hide();</script>';
echo '</div>';
}
/**
* Render introductory text and source select form
*
* @since 3.0.0
*/
protected function render_import_source_options(): void {
$upload_dir = wp_upload_dir();
/**
* Filter available import source options
*
* @since 3.0.0
* @param array $options Array of source options
*/
$source_options = apply_filters( 'wc_csv_import_suite_source_options', array(
array(
'value' => 'upload',
'title' => __( 'CSV or tab-delimited text file', 'woocommerce-csv-import-suite' ),
'description' => __( 'Upload & import data from .csv or .txt files', 'woocommerce-csv-import-suite' ),
'default' => true,
),
array(
'value' => 'url',
'title' => __( 'URL or file path', 'woocommerce-csv-import-suite' ),
'description' => __( 'Import data from an URL or path to a file on the server.', 'woocommerce-csv-import-suite' ),
),
array(
'value' => 'copypaste',
'title' => __( 'Copy/paste from file', 'woocommerce-csv-import-suite' ),
'description' => __( 'Copy & paste data from .xls, .xlsx or .csv files.', 'woocommerce-csv-import-suite' ),
),
) );
include( 'admin/views/html-import-source-options.php' );
}
/**
* Render source input form
*
* @since 3.0.0
* @param mixed $type
* @param string $source
*/
protected function render_source_input_form( $type, string $source = 'upload' ): void {
// give this instance a descriptive name to be used in the view template
$csv_importer = $this;
include( 'admin/views/html-import-source-form.php' );
}
/**
* Render import source input fields
*
* Render form fields for a specific import source type. For example,
* this function will render the upload form controls for `upload`
* source type.
*
* Custom source types are supported via the action hook.
*
* @since 3.0.0
* @param string $source
*/
protected function render_source_input_fields( string $source = 'upload' ): void {
// give this instance a descriptive name to be used in the view templates
$csv_importer = $this;
switch ( $source ) {
case 'upload':
$bytes = apply_filters( 'import_upload_size_limit', wp_max_upload_size() );
$size = size_format( $bytes );
$upload_dir = wp_upload_dir();
include( 'admin/views/html-import-source-fields-upload.php' );
break;
case 'url':
include( 'admin/views/html-import-source-fields-url.php' );
break;
case 'copypaste':
include( 'admin/views/html-import-source-fields-copypaste.php' );
break;
}
/**
* Fires when rendering input fields for an import source type
*
* Allows 3rd parties to handle custom import sources
*
* @since 3.0.0
* @param string $source
*/
do_action( 'wc_csv_import_suite_import_source_input_fields', $source );
}
/**
* Render import options & preview from CSV input
*
* @since 3.0.0
* @param array $data
* @param string $delimiter
*/
protected function render_import_options( array $data, string $delimiter ): void {
$rows = \WC_CSV_Import_Suite_Parser::generate_html_rows( $data );
// give this instance a descriptive name to be used in the view template
$csv_importer = $this;
include( 'admin/views/html-import-options.php' );
wc_enqueue_js( 'wc_csv_import_suite.is_import_options_screen = true;' );
}
/**
* Render advanced import options
*
* @since 3.0.0
*/
protected function render_advanced_import_options(): void {
// no-op, implement in subclass as needed
}
/**
* Render column mapper for CSV import
*
* @since 3.0.0
* @param array $data
* @param array $options
* @param array $raw_headers
*/
protected function render_column_mapper( array $data, array $options, array $raw_headers ): void {
$headers = array_keys( $data[2] ); // data always starts from 2nd line
$columns = [];
$sample_size = count( $data );
foreach ( $headers as $heading ) {
$importer = sanitize_key( $_GET['import'] );
// determine default mapping for heading
$mapping = \WC_CSV_Import_Suite_Parser::normalize_heading( $heading );
if ( Framework\SV_WC_Helper::str_starts_with( $heading, 'meta:' ) ) {
$mapping = 'import_as_meta';
}
if ( Framework\SV_WC_Helper::str_starts_with( $heading, 'tax:' ) ) {
$mapping = 'import_as_taxonomy';
}
/**
* Filter default CSV column <-> field mapping
*
* @since 3.0.0
* @param string $map_to Field to map the column to. Defaults to column name
* @param string $column Column name from CSV file
*/
$default_mapping = apply_filters( "wc_csv_import_suite_{$importer}_column_default_mapping", $mapping, $heading );
$columns[ $heading ] = [
'default_mapping' => $default_mapping,
'sample_values' => [],
];
foreach ( $data as $row ) {
$columns[ $heading ]['sample_values'][] = $row[$heading] ?? '';
}
}
/**
* Filter column mapping options
*
* @since 3.0.0
* @param array $mapping_options Associative array of column mapping options
* @param string $importer Importer type
* @param array $headers Normalized headers
* @param array $raw_headers Raw headers from CSV file
* @param array $columns Associative array as 'column' => 'default mapping'
*/
$mapping_options = apply_filters( 'wc_csv_import_suite_column_mapping_options', $this->get_column_mapping_options(), $importer, $headers, $raw_headers, $columns );
$mapping_options['import_as_meta'] = __( 'Custom Field with column name', 'woocommerce-csv-import-suite' );
$mapping_options['import_as_taxonomy'] = __( 'Taxonomy with column name', 'woocommerce-csv-import-suite' );
// give this instance a descriptive name to be used in the view template
$csv_importer = $this;
$form_action_url = add_query_arg( [
'import' => $_GET['import'],
'step' => self::RUN_IMPORT_STEP,
], admin_url( 'admin.php' ) );
include( 'admin/views/html-import-column-mapper.php' );
}
/**
* Generate mapping options HTML
*
* @since 3.0.0
* @param array $options
* @param string $field
* @return string HTML
*/
public function generate_mapping_options_html( array $options, string $field ): string {
$output = '';
foreach ( $options as $key => $value ) {
if ( is_array( $value ) ) {
$output .= '<optgroup label="' . esc_attr( $key ) .'">';
foreach ( $value as $_key => $_value ) {
$output .= $this->generate_select_option_html( $_key, $_value, $field );
}
$output .= '</optgroup>';
} else {
$output .= $this->generate_select_option_html( $key, $value, $field );
}
}
return $output;
}
/**
* Generate HTML for a single option
*
* @since 3.0.0
* @param mixed $value
* @param mixed $label
* @param mixed $selected
* @return string
*/
private function generate_select_option_html( $value, $label, $selected ): string {
if ( is_int( $value ) && is_string( $label ) ) {
$value = $label;
}
return '<option value="' . esc_attr( $value ) . '" ' . selected( $value, $selected, false ) . ' >' . esc_html( $label ) . '</option>';
}
/**
* Render import progress / results
*
* @since 3.0.0
* @param string $job_id
*/
protected function render_import_progress( string $job_id ): void {
// get job data
$background_jobs = wc_csv_import_suite()->get_background_import_instance();
$job = $background_jobs->get_job( $job_id );
if ( empty( $job ) ) {
echo '<p>' . sprintf( esc_html__( 'Could not find job "%s". It may have been completed a while ago, deleted or never existed.', 'woocommerce-csv-import-suite' ), esc_html( $job_id ) ) . '</p>';
return;
}
$filename = basename( $job->file_path );
$is_complete = 'completed' === $job->status;
$progress = $background_jobs->get_job_progress( $job->id );
$percentage = ! $is_complete && $progress['pos'] ? round( $progress['pos'] / $job->file_size * 100 ) : 0;
$options = (array) $job->options;
$results = $is_complete ? $job->results : $background_jobs->get_job_results( $job->id );
$some_skipped_or_failed = false;
$counts = array(
'inserted' => 0,
'merged' => 0,
'skipped' => 0,
'failed' => 0,
);
// count results
if ( ! empty( $results ) ) {
foreach ( $results as $result ) {
$counts[ $result['status'] ]++;
// check if any lines were skipped or failed
if ( ! $some_skipped_or_failed && in_array( $result['status'], array( 'skipped', 'failed' ), true ) ) {
$some_skipped_or_failed = true;
}
}
}
// prepare chart legends
$legends = array(
'inserted' => array(
'title' => sprintf( $this->i18n['count_inserted'], '<strong><span class="amount">' . $counts['inserted'] . '</span></strong> ' ),
'label' => esc_html__( 'Inserted', 'woocommerce-csv-import-suite' ),
'color' => '#5cc488',
'highlight_series' => 0,
),
'merged' => array(
'title' => sprintf( $this->i18n['count_merged'], '<strong><span class="amount">' . $counts['merged'] . '</span></strong> ' ),
'label' => esc_html__( 'Merged', 'woocommerce-csv-import-suite' ),
'color' => '#3498db',
'highlight_series' => 1,
),
'skipped' => array(
'title' => sprintf( $this->i18n['count_skipped'], '<strong><span class="amount">' . $counts['skipped'] . '</span></strong> ' ),
'label' => esc_html__( 'Skipped', 'woocommerce-csv-import-suite' ),
'color' => '#f1c40f',
'highlight_series' => 2,
),
'failed' => array(
'title' => sprintf( $this->i18n['count_failed'], '<strong><span class="amount">' . $counts['failed'] . '</span></strong> ' ),
'label' => esc_html__( 'Failed', 'woocommerce-csv-import-suite' ),
'color' => '#e74c3c',
'highlight_series' => 3,
),
);
// give this instance a descriptive name to be used in the view template
$csv_importer = $this;
$dry_run_complete_notice = $this->get_dry_run_complete_notice_text( $job );
include( 'admin/views/html-import-progress.php' );
wp_register_script( 'wc-reports', WC()->plugin_url() . '/assets/js/admin/reports.min.js', array( 'jquery', 'jquery-ui-datepicker' ), WC_VERSION );
wp_enqueue_script( 'wc-reports' );
wp_enqueue_script( 'flot' );
wp_enqueue_script( 'flot-pie' );
wc_enqueue_js( "
wc_csv_import_suite.is_import_progress_screen = true;
wc_csv_import_suite.chart_legends = " . json_encode( $legends ) . ";
wc_csv_import_suite.status_counts = " . json_encode( $counts ) . ";
wc_csv_import_suite.i18n.chart_tooltip = '" . $this->i18n['count'] . "';
wc_csv_import_suite.draw_results_chart();
" );
if ( ! $is_complete ) {
wc_enqueue_js( "
wc_csv_import_suite.i18n.dry_run_complete = '" . $dry_run_complete_notice . "';
wc_csv_import_suite.file_size = " . (int) $job->file_size . ";
wc_csv_import_suite.progress = " . (int) $progress['pos'] . ";
wc_csv_import_suite.processed_items = " . ( is_array( $results ) || is_object( $results ) ? count( $results ) : 0 ) . ";
wc_csv_import_suite.results = " . json_encode( $results ) . ";
wc_csv_import_suite.dry_run = " . ( $job->options['dry_run'] ? 'true' : 'false' ) . ";
wc_csv_import_suite.display_import_progress( '" . $job_id . "' );
" );
}
}
/**
* Gets the text for the dry run complete notice.
*
* @since 3.12.0
*
* @param stdClass $job
* @return string
*/
protected function get_dry_run_complete_notice_text( stdClass $job ) : string {
$live_import_url = add_query_arg( [
'import' => esc_attr( $_GET['import'] ),
'job_id' => $job->id,
'action' => 'run_live',
'_wpnonce' => wp_create_nonce( 'import-woocommerce' ),
], admin_url( 'admin.php' ) );
$import_settings_url = add_query_arg( [
'import' => esc_attr( $_GET['import'] ),
'step' => self::DETECT_DELIMITER_AND_RENDER_STEP,
'file' => urlencode( basename( $job->file_path ) ),
], admin_url( 'admin.php' ) );
return sprintf(
/* translators: Placeholders: %1$s, %3$s - opening <a> tag, %2$s, %4$s - closing </a> tag */
esc_html__( 'Performed a dry run with the selected file. No database records were inserted or updated. %1$sRun a live import now%2$s or %3$sChange import settings%4$s.', 'woocommerce-csv-import-suite' ),
'<a href="' . $live_import_url . '">',
'</a>',
'<a href="' . $import_settings_url . '">',
'</a>'
);
}
/**
* Handles the CSV source upload/sideload
*
* @since 3.0.0
* @param string $type
* @param string $source
* @return string File path in local filesystem or false on failure
*/
protected function handle_upload( string $type, string $source = 'upload' ) {
$file_path = false;
switch ( $source ) {
// handle uploaded files
case 'upload':
// add filter upload_dir to change default upload directory to store uploaded csv files
add_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
// add filter to randomize the imported file's name with a time-stamp
add_filter( 'wp_handle_upload_prefilter', [ $this, 'randomize_imported_file_name' ] );
$results = wp_import_handle_upload();
// remove filter wp_handle_upload_prefilter
remove_filter( 'wp_handle_upload_prefilter', [ $this, 'randomize_imported_file_name' ] );
// remove filter upload_dir
remove_filter( 'upload_dir', [ $this, 'change_upload_dir' ] );
if ( isset( $results['error'] ) ) {
$this->handle_upload_error( $results['error'] );
return false;
}
$file_path = $results['file'];
break;
// handle URL or path input
case 'url':
if ( empty( $_POST['url'] ) ) {
$error = __( 'Please provide a file path or URL', 'woocommerce-csv-import-suite' );
$this->handle_upload_error( $error );
return false;
}
// if this is an URL, try to sideload the file
if ( filter_var( $_POST['url'], FILTER_VALIDATE_URL ) ) {
require_once( ABSPATH . 'wp-admin/includes/file.php' );
// download the URL to a temp file
$temp_file = download_url( $_POST['url'], 5 );
if ( is_wp_error( $temp_file ) ) {
$this->handle_upload_error( $temp_file );
return false;
}
// array based on $_FILE as seen in PHP file uploads
$input = [
'name' => basename( $_POST['url'] ),
'type' => 'image/png',
'tmp_name' => $temp_file,
'error' => 0,
'size' => filesize( $temp_file ),
];
// move the temporary file into the uploads directory
$results = wp_handle_sideload( $input, [ 'test_form' => false ] );
if ( ! empty( $results['error'] ) ) {
$this->handle_upload_error( $results['error'] );
return false;
}
$file_path = $results['file'];
}
// perhaps it's a path to file?
else {
if ( ! is_readable( $_POST['url'] ) ) {
$error = sprintf( __( 'Could not find the file %s', 'woocommerce-csv-import-suite' ), esc_html( $_POST['url'] ) );
$this->handle_upload_error( $error );
return false;
}
$file_path = esc_attr( $_POST['url'] );
}
break;
// handle copy-pasted data
case 'copypaste':
$data = stripslashes( $_POST['copypaste'] );
if ( empty( $data ) ) {
$error = __( 'Please enter some data to import', 'woocommerce-csv-import-suite' );
$this->handle_upload_error( $error );
return false;
}
$results = wp_upload_bits( $type . '-' . date( 'Ymd-His' ) . '.csv', null, $data );
if ( ! empty( $results['error'] ) ) {
$this->handle_upload_error( $results['error'] );
return false;
}
$file_path = $results['file'];
break;
}
return $file_path;
}
/**
* Ensure that the provided file path is readable
*
* If file not readbale, will redirect user back to the previous screen
* with an appropriate error message.
*
* @since 3.1.0
* @param string $file_path
*/
private function ensure_file_is_readable( string $file_path ): void {
if ( ! is_readable( $file_path ) ) {
/* translators: Placeholders: %s - file path */
$this->handle_upload_error( sprintf( __( 'Cannot open file %s for importing. The file may not exist or is not readable by WordPress.', 'woocommerce-csv-import-suite' ), $file_path ) );
}
}
/**
* Handle source upload error
*
* @since 3.0.0
* @param string|WP_Error $error Error message
*/
protected function handle_upload_error( $error ): void {
$message = is_wp_error( $error ) ? $error->get_error_message() : $error;
$message = sprintf( esc_html__( 'Sorry, there has been an error: %s', 'woocommerce-csv-import-suite' ), $message );
wc_csv_import_suite()->get_message_handler()->add_error( $message );
wp_redirect( wp_get_referer() );
exit;
}
/**
* Import a CSV file, or a part of it
*
* @since 3.0.0
* @param string $file Path to file
* @param array $options General & import type specific options
*/
public function import( string $file, array $options = [] ): void {
$this->file = $file;
wp_defer_term_counting( true );
wp_defer_comment_counting( true );
// read raw data from CSV file
[ $parsed_data, $raw_headers, $position, $last_line_num ] = \WC_CSV_Import_Suite_Parser::parse( $file, $options );
$this->import_progress = [
'line' => $last_line_num,
'pos' => $position
];
$this->import_lines( $parsed_data, $last_line_num, $options, $raw_headers );
// done importing, cleanup
foreach ( get_taxonomies() as $tax ) {
delete_option( "{$tax}_children" );
_get_term_hierarchy( $tax );
}
wp_defer_term_counting( false );
wp_defer_comment_counting( false );
do_action( 'import_end' );
}
/**
* Import each line one-by-one from CSV
*
* @since 3.0.0
* @param array $parsed_data Parsed data from CSV
* @param int $last_line_num Last parsed line number in parsed data
* @param array $options Import/parsing options
* @param array $raw_headers Raw headers from CSV file
*/
protected function import_lines( array $parsed_data, int $last_line_num, array $options, array $raw_headers ): void {
$is_multiline_format = $this->is_multiline_format( $raw_headers );
// loop over extracted lines and import them one by one
for ( $line_num = $options['start_line']; $line_num <= $last_line_num; ) {
wc_csv_import_suite()->log( '---' );
// the line num key may not be set in cases where a single item spans
// across multiple lines and the total number of lines in CSV is more than
// total number of parsed items. if this ever happens, stop importing.
if ( ! isset( $parsed_data[ $line_num ] ) ) {
break;
}
$item = $parsed_data[ $line_num ];
$parsed_item = null;
$related_items = null;
// set internal current line number counter
$this->line_num = $line_num;
// the item might span across multiple lines, try to look up all the
// lines related to this item
if ( $is_multiline_format && $item_identifier = $this->get_item_identifier( $item ) ) {
$first_line_num = $line_num; // store first line number for this item
$related_items = $this->find_related_items( $item_identifier, $parsed_data, $line_num, $options );
// looks like some lines belong together to form a single item. Let's
// parse them all one by one
if ( ! empty( $related_items ) ) {
$related_items = [ $first_line_num => $item ] + $related_items;
$parsed_item = $this->parse_multiline_item( $item_identifier, $related_items, $options, $raw_headers );
}
}
// single item per line - this one's easy!
if ( empty( $related_items ) && ! $parsed_item ) {
try {
$parsed_item = $this->parse_item( $item, $options, $raw_headers );
} catch ( WC_CSV_Import_Suite_Import_Exception $e ) {
$this->add_import_result( 'skipped', $e->getMessage() );
}
}
if ( $parsed_item ) {
$this->process_item( $parsed_item, $options, $raw_headers );
}
// increment line index manually
$line_num++;
unset( $item, $parsed_item );
}
}
/**
* Find related items (lines) in a multi-line format CSV file
*
* @since 3.0.0
* @param mixed $item_identifier Identifier used to match related lines
* @param array $parsed_data Parsed data from CSV
* @param int $line_num Current line number counter. Passed by reference
* @param array $options Import options
* @return array Array of related items/lines
*/
protected function find_related_items( $item_identifier, array $parsed_data, int &$line_num, array $options ): array {
$related_items = array();
do {
$next_item = $next_parsed_item = null;
$item_continued = $results = false;
// the next line has already been read from the CSV file
if ( isset( $parsed_data[ $line_num + 1 ] ) ) {
$next_item = $parsed_data[ $line_num + 1 ];
}
// read the next line from CSV file
else {
$_options = $options;
$_options['start_pos'] = $this->import_progress['pos']; // we continue from last pointer position
$_options['start_line'] = $this->import_progress['line'] + 1; // but we need to increment the line number ourselves
$_options['max_lines'] = 1;
// read raw data from CSV file
$results = \WC_CSV_Import_Suite_Parser::parse( $this->file, $_options );
$next_item = ! empty( $results[0] ) ? $results[0][ $line_num + 1 ] : null;
}
// an item (line) was successfully found
if ( ! empty( $next_item ) ) {
// check if the next line is related to the last (current) line
$item_continued = $item_identifier == $this->get_item_identifier( $next_item );
// if the next item identifier matches current, we know those lines
// belong together to form a single item
if ( $item_continued ) {
// increment import progress
// NB! Intentional overwrite of $line_num
$line_num++;
$related_items[ $line_num ] = $next_item;
$this->import_progress = array(
'line' => $line_num,
'pos' => $results[2], // last file position pointer
);
}
}
} while ( $next_item && $item_continued );
return $related_items;
}
/**
* Parse related items in a multi-line CSV format
*
* @since 3.0.0
* @param mixed $item_identifier Common identifier for the related lines
* @param array $related_items Items that are related and make up 1 single item
* @param array $options Import options
* @param array $raw_headers Raw headers from CSV
* @return string[] parsed data
*/
protected function parse_multiline_item( $item_identifier, array $related_items, array $options, array $raw_headers ): ?array {
$parsed_items = array();
$skipped_items = array();
$related = implode( ', ', array_keys( $related_items ) );
wc_csv_import_suite()->log( sprintf( __( '> Preparing multi-line item %s (rows %s)', 'woocommerce-csv-import-suite' ), $item_identifier, $related ) );
foreach ( $related_items as $line_num => $item ) {
// set the internal line number counter - this will ensure that any
// validation errors are reported with correct line numbers
$this->line_num = $line_num;
$_parsed_item = null;
try {
$_parsed_item = $this->parse_item( $item, $options, $raw_headers );
} catch ( WC_CSV_Import_Suite_Import_Exception $e ) {
$this->add_import_result( 'skipped', $e->getMessage() );
$skipped_items[] = $line_num;
}
if ( $_parsed_item ) {
$parsed_items[ $line_num ] = $_parsed_item;
}
}
// one or more lines were skipped. we want to skip importing all related
// lines to avoid data corruption.
if ( ! empty( $skipped_items ) ) {
$delta = array_diff( array_keys( $related_items ), $skipped_items );
$skipped = implode( ', ', $delta );
wc_csv_import_suite()->log( sprintf( __( '> Skipped importing rows %s due to issues with related rows.', 'woocommerce-csv-import-suite' ), $skipped ) );
return null;
}
// no errors occurred in parsing stage, let's merge the parsed items
// into a single item
return $this->merge_parsed_items( $parsed_items );
}
/**
* Checks whether the CSV uses a multi-line format
*
* Checks whether data for a single item spans across multiple physical lines
* in the CSV file.
*
* Implement at subclass level.
*
* @since 3.0.0
* @param array $raw_headers Raw CSV headers
* @return bool
*/
protected function is_multiline_format( array $raw_headers ): bool {
return false;
}
/**
* Get identifier for a single item
*
* Utility method to get a unique identifier for a single item in a CSV file.
* Useful for detecting physical lines in a CSV file to form a single item.
*
* @since 3.0.0
* @param array $data Item data, either raw data from CSV parser, mapped to
* columns, or parsed item data
* @return int|string|null
*/
public function get_item_identifier( array $data ) {
return null;
}
/**
* Merge data from multiple parsed lines into one item
*
* Must be implemented at subclass level. By default, will return the first
* item.
*
* @since 3.0.0
* @param array $items Array of parsed items
* @return array
*/
protected function merge_parsed_items( array $items ) : array {
return array_shift( $items );
}
/**
* Parse an item into something usable
*
* Override this method on subclass level
*
* @since 3.0.0
* @param array $item Raw item data from CSV
* @param array $options Optional. Options
* @param array $raw_headers Optional. Raw headers
* @return mixed|bool Parsed item or false on failure
*/
protected function parse_item( array $item, array $options = [], array $raw_headers = [] ) {
return $item;
}
/**
* Process an item
*
* This usually means inserting or updating something in the database.
* Override this method on subclass level.
*
* @since 3.0.0
* @param mixed $item Parsed item ready for processing
* @param array $options Optional. Options
* @param array $raw_headers Optional. Raw headers
* @return mixed
*/
protected function process_item( $item, $options = array(), $raw_headers = array() ) {
// no-op
}
/**
* Parses taxonomy & terms from a key and its values.
*
* @since 3.0.0
*
* @param string $key
* @param string $value
* @return array|null Array with parsed taxonomy name and it's terms, or null on failure
*/
public function parse_taxonomy_terms( string $key, string $value ): ?array {
// get taxonomy
$taxonomy = trim( str_replace( 'tax:', '', $key ) );
// exists?
if ( ! taxonomy_exists( $taxonomy ) ) {
wc_csv_import_suite()->log( sprintf( __( '> > Skipping taxonomy "%s" - it does not exist.', 'woocommerce-csv-import-suite' ), $taxonomy ) );
return null;
}
// get terms - ID => parent
$terms = [];
$raw_terms = explode( '|', $value );
$raw_terms = array_map( 'trim', $raw_terms );
// handle term hierarchy (>)
foreach ( $raw_terms as $raw_term ) {
if ( Framework\SV_WC_Helper::str_exists( $raw_term, '>' ) ) {
$raw_term = explode( '>', $raw_term );
$raw_term = array_map( 'trim', $raw_term );
$raw_term = array_map( 'esc_html', $raw_term );
$raw_term = array_filter( $raw_term );
$parent = 0;
$loop = 0;
foreach ( $raw_term as $term ) {
$loop ++;
$term_id = '';
if ( isset( $this->inserted_terms[ $taxonomy ][ $parent ][ $term ] ) ) {
$term_id = $this->inserted_terms[ $taxonomy ][ $parent ][ $term ];
} elseif ( $term ) {
// check term existence
$term_may_exist = term_exists( $term, $taxonomy, absint( $parent ) );
if ( is_array( $term_may_exist ) ) {
$possible_term = get_term( $term_may_exist['term_id'], $taxonomy );
if ( $possible_term->parent == $parent ) {
$term_id = $term_may_exist['term_id'];
}
}
if ( ! $term_id ) {
// create appropriate slug
$slug = array();
for ( $i = 0; $i < $loop; $i ++ ) {
$slug[] = $raw_term[ $i ];
}
$slug = sanitize_title( implode( '-', $slug ) );
$t = wp_insert_term( $term, $taxonomy, [ 'parent' => $parent, 'slug' => $slug ] );
if ( ! is_wp_error( $t ) ) {
$term_id = $t['term_id'];
} else {
wc_csv_import_suite()->log( sprintf( __( '> > (' . $this->get_line_num() . ') Failed to import term %s, parent %s - %s', 'woocommerce-csv-import-suite' ), sanitize_text_field( $term ), sanitize_text_field( $parent ), sanitize_text_field( $taxonomy ) ) );
break;
}
}
$this->inserted_terms[ $taxonomy ][ $parent ][ $term ] = $term_id;
}
if ( ! $term_id ) {
break;
}
// sdd to terms, ready to set if this is the final term
if ( count( $raw_term ) === $loop ) {
$terms[] = $term_id;
}
$parent = $term_id;
}
} else {
$term_id = '';
$raw_term = esc_html( $raw_term );
if ( isset( $this->inserted_terms[ $taxonomy ][0][ $raw_term ] ) ) {
$term_id = $this->inserted_terms[ $taxonomy ][0][ $raw_term ];
} elseif ( $raw_term ) {
// Check term existence
$term_exists = term_exists( $raw_term, $taxonomy, 0 );
$term_id = is_array( $term_exists ) ? $term_exists['term_id'] : 0;
if ( ! $term_id ) {
$t = wp_insert_term( trim( $raw_term ), $taxonomy, [ 'parent' => 0 ] );
if ( ! is_wp_error( $t ) ) {
$term_id = $t['term_id'];
} else {
wc_csv_import_suite()->log( sprintf( __( '> > Failed to import term %s %s', 'woocommerce-csv-import-suite' ), esc_html( $raw_term ), esc_html( $taxonomy ) ) );
break;
}
}
$this->inserted_terms[ $taxonomy ][0][ $raw_term ] = $term_id;
}
// store terms for later insertion
if ( $term_id ) {
$terms[] = $term_id;
}
}
}
return ! empty( $terms ) ? array( $taxonomy, $terms ) : null;
}
/**
* Process terms
*
* @since 3.0.0
* @param int $post_id
* @param array|mixed $terms_to_process
*/
protected function process_terms( int $post_id, $terms_to_process ): void {
if ( empty( $terms_to_process ) || ! is_array( $terms_to_process ) ) {
return;
}
// add categories, tags and other terms
$terms_to_set = [];
foreach ( $terms_to_process as $term_group ) {
$taxonomy = $term_group['taxonomy'];
$terms = $term_group['terms'];
if ( ! $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
continue;
}
if ( ! is_array( $terms ) ) {
$terms = [ $terms ];
}
$terms_to_set[ $taxonomy ] = [];
foreach ( $terms as $term_id ) {
if ( $term_id ) {
$terms_to_set[ $taxonomy ][] = (int) $term_id;
}
}
}
foreach ( $terms_to_set as $tax => $ids ) {
wp_set_post_terms( $post_id, $ids, $tax, false );
}
}
/**
* Log a row's import status
*
* @since 3.0.0
* @param string $status Status
* @param string $message Optional
* @param bool $log Optional. Whether to log the result or not. Defaults to true
*/
public function add_import_result( string $status, string $message = '', bool $log = true ): void {
$this->import_results[ $this->get_line_num() ] = [
'status' => $status,
'message' => $message,
];
if ( $log ) {
$labels = [
'inserted' => esc_html__( 'Inserted', 'woocommerce-csv-import-suite' ),
'merged' => esc_html__( 'Merged', 'woocommerce-csv-import-suite' ),
'skipped' => esc_html__( 'Skipped', 'woocommerce-csv-import-suite' ),
'failed' => esc_html__( 'Failed', 'woocommerce-csv-import-suite' ),
];
$status_label = $labels[$status] ?? $status;
$log_message = sprintf( "> > %s. %s", $status_label, $message );
wc_csv_import_suite()->log( $log_message );
}
}
/**
* Get current line number
*
* @since 3.3.0
* @return int
*/
public function get_line_num(): int {
return $this->line_num;
}
/**
* Get import results
*
* @since 3.0.0
* @return array
*/
public function get_import_results(): array {
return $this->import_results;
}
/**
* Get import progress
*
* @since 3.0.0
* @return array
*/
public function get_import_progress(): array {
return $this->import_progress;
}
/**
* Guess CSV delimiter used in input
*
* @since 3.0.0
* @return string
*/
protected function guess_delimiter( $input ): string {
$lines = explode( '\n', $input );
$line_count = count( $lines );
$best_delta = null;
$best_delimiter = ','; // always fall back to comma
$prev_field_count = null;
foreach ( array_keys( $this->get_valid_delimiters() ) as $delimiter ) {
$delta = $avg_field_count = 0;
$prev_field_count = null;
$total_field_count = 0;
// try to parse the lines with the current delimiter
foreach ( $lines as $line_num => $line ) {
$data = $this->str_getcsv( $line, $delimiter );
if ( empty( $data ) ) {
continue;
}
$field_count = count( $data );
$total_field_count += $field_count;
if ( null === $prev_field_count ) {
$prev_field_count = $field_count;
}
else if ( $field_count > 1 ) {
$delta += abs( $field_count - $prev_field_count );
$prev_field_count = $field_count;
}
}
$avg_field_count = $total_field_count / $line_count;
if ( null === $best_delta || ( $delta < $best_delta && $avg_field_count >= 2 ) ) {
$best_delta = $delta;
$best_delimiter = $delimiter;
}
}
return $best_delimiter;
}
/**
* Parse CSV data from a string
*
* Added to provide compatibility with PHP versions < 5.3
*
* @since 3.1.0
* @param string $input
* @param string $delimiter
* @return array
*/
private function str_getcsv( string $input, string $delimiter ): array {
if ( function_exists( 'str_getcsv' ) ) {
return str_getcsv( $input, $delimiter );
}
$handle = fopen( 'php://temp', 'r+' );
fwrite( $handle, $input );
rewind( $handle );
$data = fgetcsv( $handle, $delimiter );
fclose( $handle );
return $data;
}
/**
* Count the number of lines in a TXT/CSV file
*
* @since 3.0.0
* @param string $file Path to file
* @return int|bool Number of lines in file, false on failure
*/
protected function count_lines_in_file( string $file ) {
$count = -1;
// first, try *nix commands. This will only work if host has
// enabled `exec()`, `wc` command is available and the file
// uses LF or CRLF line endings. It will fail (report 0 lines)
// on CR line endings.
if ( function_exists( 'exec' ) ) {
exec( 'wc -l < ' . escapeshellarg( $file ), $result, $exit );
// no exit code means the command executed successfully
if ( ! $exit && isset( $result[0] ) ) {
$count = (int) $result[0];
}
}
// if the previous method failed, use PHP
if ( $count < 1 ) {
$count = -1; // PHP line counts are off by 1
@ini_set( 'auto_detect_line_endings', true );
$handle = fopen( $file, "r" );
while( ! feof( $handle ) ) {
$line = fgets( $handle );
$count++;
}
fclose( $handle );
}
return $count > 0 ? $count : false;
}
/**
* Get a list of possible valid delimiters
*
* @since 3.0.0
* @return array List of valid delimiters
*/
public function get_valid_delimiters(): array {
if ( ! isset( $this->valid_delimiters ) ) {
/**
* Filter the list of available valid delimiters
*
* @since 3.0.0
* @param array $delimiters
*/
$this->valid_delimiters = apply_filters( 'wc_csv_import_suite_delimiter_choices', [
"," => __( 'Comma', 'woocommerce-csv-import-suite' ),
";" => __( 'Semicolon', 'woocommerce-csv-import-suite' ),
"\t" => __( 'Tab', 'woocommerce-csv-import-suite' ), // double quotes are significant
] );
}
return $this->valid_delimiters;
}
/**
* Added to http_request_timeout filter to force timeout at 60 seconds during import
*
* @see \WP_Importer::bump_request_timeout()
* @since 3.0.0
* @param int $val timeout value
* @return int 60 seconds
*/
public function bump_request_timeout( $val ): int {
return MINUTE_IN_SECONDS;
}
/**
* Get the title for the importer
*
* @since 3.0.0
* @return string
*/
public function get_title(): string {
return $this->title;
}
/**
* Change default upload directory to csv_imports to store uploaded csv files.
*
* @since 3.4.0
* @param array $dirs array of upload directory data with keys of 'path', 'url', 'subdir, 'basedir', and 'error'.
* @return array
*/
public function change_upload_dir( array $dirs ): array {
$subdir = '/csv_imports';
$dirs['subdir'] = $subdir;
$dirs['path'] = $dirs['basedir'] . $subdir;
$dirs['url'] = $dirs['baseurl'] . $subdir;
return $dirs;
}
/**
* Randomize the imported file's name with a time-stamp.
*
* @since 3.4.0
* @param array $file an array of data for a single file
* @return array
*/
public function randomize_imported_file_name( array $file ): array {
$file['name'] = uniqid( '', true ) . '-' . $file['name'];
return $file;
}
/**
* Returns the file path if the file was successfully uploaded.
*
* @since 3.12.0
*
* @param string $file_name the file name
* @return string|null
*/
protected function get_uploaded_csv_file_path( string $file_name ) : ?string {
$base_upload_dir = wp_upload_dir();
$base_csv_imports_upload_path = "{$base_upload_dir['basedir']}/csv_imports";
$file_path = "{$base_csv_imports_upload_path}/{$file_name}";
if ( file_exists( $file_path ) ) {
return $file_path;
}
$file_path = $base_upload_dir['basedir'] . '/' . date( 'Y' ) . '/' . date( 'm' ) . '/' . $file_name;
if ( file_exists( $file_path ) ) {
return $file_path;
}
return null;
}
}