assignments_hook = add_menu_page(
__( 'YMM Hero', 'ymm-hero' ),
__( 'YMM Hero', 'ymm-hero' ),
'manage_woocommerce',
$parent,
array( $this, 'render_assignments_page' ),
// 'none' leaves the icon slot empty so enqueue_menu_icon_style() can mask
// the mark in: as an core only fades it, so it never picked up the
// grey/blue/white states the dashicon menu items cycle through.
'none',
58
);
add_submenu_page(
$parent,
__( 'Product Assignments', 'ymm-hero' ),
__( 'Product Assignments', 'ymm-hero' ),
'manage_woocommerce',
$parent,
array( $this, 'render_assignments_page' )
);
$this->vehicles_hook = add_submenu_page(
$parent,
__( 'Vehicle DB', 'ymm-hero' ),
__( 'Vehicle DB', 'ymm-hero' ),
'manage_woocommerce',
'ymmhero-vehicles',
array( $this, 'render_vehicles_page' )
);
$this->import_hook = add_submenu_page(
$parent,
__( 'Import', 'ymm-hero' ),
__( 'Import', 'ymm-hero' ),
'manage_woocommerce',
'ymmhero-import',
array( $this, 'render_import_page' )
);
$this->settings_hook = add_submenu_page(
$parent,
__( 'Settings', 'ymm-hero' ),
__( 'Settings', 'ymm-hero' ),
'manage_woocommerce',
'ymmhero-settings',
array( $this, 'render_settings_page' )
);
/**
* One screen serves every registered add-on, reached from the shell's own
* sidebar rather than the WordPress submenu. It is registered with no parent
* — WordPress's hidden-page form — because registering it under $parent and
* then calling remove_submenu_page() left the page out of $submenu, which is
* what user_can_access_admin_page() resolves the parent from: the hook name it
* then looked for ("admin_page_…") was not the one registration stored
* ("ymm-search_page_…"), so every add-on link died on "you are not allowed to
* access this page".
*/
$this->addons_hook = add_submenu_page(
'',
__( 'Add-ons', 'ymm-hero' ),
__( 'Add-ons', 'ymm-hero' ),
'manage_woocommerce',
YMMHERO_Addons::PAGE_SLUG,
array( 'YMMHERO_Addons', 'render_page' )
);
}
public function render_vehicles_page() {
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
$action2 = isset( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : '';
if ( '' === $action || '-1' === $action ) {
$action = ( '' !== $action2 && '-1' !== $action2 ) ? $action2 : '';
}
if ( $action === 'delete' && ! empty( $_REQUEST['ids'] ) ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
check_admin_referer( 'bulk-vehicles' );
$ids = array_filter( array_map( 'absint', (array) wp_unslash( $_REQUEST['ids'] ) ) );
$count = 0;
foreach ( $ids as $id ) {
if ( YMMHERO_DB::delete_vehicle( $id ) !== false ) {
$count++;
}
}
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-vehicles&message=bulk_deleted&count=' . $count ) );
exit;
}
if ( $action === 'add' ) {
$vehicle = null;
include YMMHERO_PLUGIN_DIR . 'admin/views/vehicle-form.php';
return;
}
if ( $action === 'edit' ) {
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
$vehicle = $id ? YMMHERO_DB::get_vehicle( $id ) : null;
if ( ! $vehicle ) {
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-vehicles&message=not_found' ) );
exit;
}
include YMMHERO_PLUGIN_DIR . 'admin/views/vehicle-form.php';
return;
}
include YMMHERO_PLUGIN_DIR . 'admin/views/vehicles-list.php';
}
public function render_assignments_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
if ( 'add' === $action ) {
$has_submodels = self::submodel_supported();
include YMMHERO_PLUGIN_DIR . 'admin/views/add-assignment.php';
return;
}
if ( 'assign' === $action ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$vehicle_id = isset( $_GET['vehicle_id'] ) ? absint( $_GET['vehicle_id'] ) : 0;
$vehicle = $vehicle_id ? YMMHERO_DB::get_vehicle( $vehicle_id ) : null;
if ( ! $vehicle ) {
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-assignments&message=not_found' ) );
exit;
}
include YMMHERO_PLUGIN_DIR . 'admin/views/assign-products.php';
return;
}
include YMMHERO_PLUGIN_DIR . 'admin/views/assignments-list.php';
}
public function handle_save_assignments() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
check_admin_referer( 'ymmhero_save_assignments' );
$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
// Without the sub-model add-on a Y/M/M can name several rows that free has no
// way to tell apart, so the add screen posts every id its selection matched and
// the products land on all of them — the same fan-out the CSV importer does.
$vehicle_ids = isset( $_POST['vehicle_ids'] )
? array_filter( array_map( 'absint', explode( ',', sanitize_text_field( wp_unslash( $_POST['vehicle_ids'] ) ) ) ) )
: array( isset( $_POST['vehicle_id'] ) ? absint( $_POST['vehicle_id'] ) : 0 );
$vehicle_ids = array_values( array_filter(
array_unique( $vehicle_ids ),
function ( $id ) {
return $id && YMMHERO_DB::get_vehicle( $id );
}
) );
if ( empty( $vehicle_ids ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-assignments&message=not_found' ) );
exit;
}
$product_ids = isset( $_POST['ymmhero_product_ids'] )
? array_filter( array_map( 'absint', (array) wp_unslash( $_POST['ymmhero_product_ids'] ) ) )
: array();
foreach ( $vehicle_ids as $vehicle_id ) {
// The assign screen shows the vehicle's current products, so submitting it
// is a replace and an emptied box means "remove all". The add screen starts
// blank and never showed them, so there it can only add.
$ids = 'add' === $mode
? array_unique( array_merge( YMMHERO_DB::get_product_ids_by_vehicle( $vehicle_id ), $product_ids ) )
: $product_ids;
YMMHERO_DB::set_vehicle_products( $vehicle_id, $ids );
}
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-assignments&message=assignments_saved' ) );
exit;
}
/**
* WooCommerce registers wc-enhanced-select on admin_enqueue_scripts at the
* default priority, and this plugin loads before WooCommerce — so enqueueing
* at priority 10 would silently no-op against an unregistered handle.
*/
public function enqueue_product_search_assets( $hook ) {
if ( $hook !== $this->assignments_hook ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
if ( 'assign' !== $action && 'add' !== $action ) {
return;
}
wp_enqueue_style( 'woocommerce_admin_styles' );
wp_enqueue_script( 'wc-enhanced-select' );
if ( 'add' !== $action ) {
return;
}
wp_enqueue_script(
'ymmhero-add-assignment',
YMMHERO_PLUGIN_URL . 'admin/assets/add-assignment.js',
array(),
self::asset_version( 'admin/assets/add-assignment.js' ),
true
);
$picker = YMMHERO_DB::get_vehicle_picker_rows();
wp_localize_script( 'ymmhero-add-assignment', 'ymmheroAddAssignment', array(
'rows' => $picker['rows'],
'i18n' => array(
'anyYear' => __( 'Select a year', 'ymm-hero' ),
'anyMake' => __( 'Select a make', 'ymm-hero' ),
'anyModel' => __( 'Select a model', 'ymm-hero' ),
'anySubmodel' => __( 'Select a sub-model', 'ymm-hero' ),
),
) );
}
public function render_import_page() {
include YMMHERO_PLUGIN_DIR . 'admin/views/csv-import.php';
}
public function render_settings_page() {
include YMMHERO_PLUGIN_DIR . 'admin/views/settings-page.php';
}
public function handle_save_vehicle() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
check_admin_referer( 'ymmhero_save_vehicle' );
$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
$year = isset( $_POST['year'] ) ? absint( $_POST['year'] ) : 0;
$make = isset( $_POST['make'] ) ? sanitize_text_field( wp_unslash( $_POST['make'] ) ) : '';
$model = isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : '';
$errors = array();
if ( $year < 1900 || $year > 2100 ) {
$errors[] = 'invalid_year';
}
if ( $make === '' ) {
$errors[] = 'missing_make';
}
if ( $model === '' ) {
$errors[] = 'missing_model';
}
if ( ! empty( $errors ) ) {
$redirect = admin_url( 'admin.php?page=ymmhero-vehicles&action=' . ( $id ? 'edit&id=' . $id : 'add' ) . '&message=validation_error' );
wp_safe_redirect( $redirect );
exit;
}
$data = apply_filters( 'ymmhero_vehicle_save_data', array(
'year' => $year,
'make' => $make,
'model' => $model,
), $id, map_deep( wp_unslash( $_POST ), 'sanitize_text_field' ) );
// Absent means "leave alone", so a vehicle's stored sub-model survives a
// save made from a form that never rendered the field.
$submodel = array_key_exists( 'submodel', $data ) ? $data['submodel'] : null;
if ( $id ) {
YMMHERO_DB::update_vehicle( $id, $data['year'], $data['make'], $data['model'], $submodel );
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-vehicles&message=updated' ) );
} else {
YMMHERO_DB::insert_vehicle( $data['year'], $data['make'], $data['model'], null === $submodel ? '' : $submodel );
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-vehicles&message=added' ) );
}
exit;
}
public function handle_delete_vehicle() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
check_admin_referer( 'ymmhero_delete_vehicle_' . $id );
if ( $id ) {
YMMHERO_DB::delete_vehicle( $id );
}
wp_safe_redirect( admin_url( 'admin.php?page=ymmhero-vehicles&message=deleted' ) );
exit;
}
public function handle_download_sample_vehicles() {
$this->stream_sample_csv( 'sample-vehicles.csv' );
}
public function handle_download_sample_assignments() {
$this->stream_sample_csv( 'sample-assignments.csv' );
}
public function handle_download_sample_vehicles_submodel() {
$this->stream_sample_csv( 'sample-vehicles-submodel.csv' );
}
public function handle_download_sample_assignments_submodel() {
$this->stream_sample_csv( 'sample-assignments-submodel.csv' );
}
/**
* The default samples leave the sub-model column blank on purpose: the free
* plugin has no Sub-model column on the vehicles list, so a store that
* imported populated samples would see rows it cannot tell apart. The
* populated samples are therefore only offered once something answers this
* filter — the add-on does.
*/
public static function submodel_supported() {
return (bool) apply_filters( 'ymmhero_submodel_supported', false );
}
private function stream_sample_csv( $filename ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
$path = YMMHERO_PLUGIN_DIR . 'admin/assets/' . $filename;
if ( ! file_exists( $path ) ) {
wp_die( esc_html__( 'File not found.', 'ymm-hero' ) );
}
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . filesize( $path ) );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams the generated CSV export to the browser; WP_Filesystem has no streaming API.
readfile( $path );
exit;
}
public function handle_import_csv() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
check_admin_referer( 'ymmhero_import_csv' );
$redirect_base = admin_url( 'admin.php?page=ymmhero-import' );
$tmp_name = $this->validate_csv_upload( 'ymmhero_csv_file', $redirect_base );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV is streamed row by row so large imports do not exhaust memory; WP_Filesystem has no streaming API.
$handle = fopen( $tmp_name, 'r' );
if ( ! $handle ) {
wp_safe_redirect( $redirect_base . '&message=import_no_file' );
exit;
}
if ( function_exists( 'set_time_limit' ) ) {
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- best-effort raise for large CSV imports; function_exists() is false when the host disables it, and the import chunks regardless.
set_time_limit( 300 );
}
$start_time = microtime( true );
// First pass: count data rows so we know whether to chunk the import.
$total_rows = 0;
$first_row = true;
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
if ( self::is_blank_csv_row( $row ) ) {
continue;
}
if ( $first_row ) {
$first_row = false;
if ( self::is_csv_header_row( $row ) ) {
continue;
}
}
$total_rows++;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the streamed CSV handle opened above.
fclose( $handle );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV is streamed row by row so large imports do not exhaust memory; WP_Filesystem has no streaming API.
$handle = fopen( $tmp_name, 'r' );
global $wpdb;
$chunk_size = 500;
$use_chunking = $total_rows > 5000;
$imported = 0;
$skipped_duplicate = 0;
$skipped_invalid = 0;
$first_row = true;
$row_index = 0;
$index = array( 'year' => 0, 'make' => 1, 'model' => 2, 'submodel' => 3 );
if ( $use_chunking ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'START TRANSACTION' );
}
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
if ( self::is_blank_csv_row( $row ) ) {
continue;
}
if ( $first_row ) {
$first_row = false;
if ( self::is_csv_header_row( $row ) ) {
$index = self::csv_column_index(
$row,
$index,
'vehicles',
array( 'year', 'make', 'model' )
);
continue;
}
}
if ( count( $row ) < 3 ) {
$skipped_invalid++;
} else {
// The sub-model column is stored but never displayed by this plugin —
// carrying it through keeps a full catalogue importable in one pass.
$columns = apply_filters( 'ymmhero_csv_import_columns', array(
'year' => absint( self::csv_value( $row, $index, 'year' ) ),
'make' => self::csv_value( $row, $index, 'make' ),
'model' => self::csv_value( $row, $index, 'model' ),
'submodel' => self::csv_value( $row, $index, 'submodel' ),
), $row );
$year = absint( $columns['year'] );
$make = (string) $columns['make'];
$model = (string) $columns['model'];
$submodel = (string) $columns['submodel'];
if ( $year < 1900 || $year > 2100 || $make === '' || $model === '' ) {
$skipped_invalid++;
} elseif ( YMMHERO_DB::vehicle_exists( $year, $make, $model, $submodel ) ) {
$skipped_duplicate++;
} else {
YMMHERO_DB::insert_vehicle( $year, $make, $model, $submodel );
$imported++;
}
}
$row_index++;
if ( $use_chunking && $row_index % $chunk_size === 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'COMMIT' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'START TRANSACTION' );
}
}
if ( $use_chunking ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'COMMIT' );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the streamed CSV handle opened above.
fclose( $handle );
$elapsed = round( microtime( true ) - $start_time, 2 );
$redirect = add_query_arg( array(
'message' => 'imported',
'imported' => $imported,
'skipped_duplicate' => $skipped_duplicate,
'skipped_invalid' => $skipped_invalid,
'elapsed' => $elapsed,
), $redirect_base );
wp_safe_redirect( $redirect );
exit;
}
public function handle_import_assignments() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) );
}
check_admin_referer( 'ymmhero_import_assignments' );
$redirect_base = admin_url( 'admin.php?page=ymmhero-import' );
$tmp_name = $this->validate_csv_upload( 'ymmhero_assignments_file', $redirect_base );
$create_missing = ! empty( $_POST['ymmhero_create_missing_vehicles'] );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV is streamed row by row so large imports do not exhaust memory; WP_Filesystem has no streaming API.
$handle = fopen( $tmp_name, 'r' );
if ( ! $handle ) {
wp_safe_redirect( $redirect_base . '&message=import_no_file' );
exit;
}
if ( function_exists( 'set_time_limit' ) ) {
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- best-effort raise for large CSV imports; function_exists() is false when the host disables it, and the import chunks regardless.
set_time_limit( 300 );
}
$start_time = microtime( true );
global $wpdb;
$added = 0;
$created_vehicles = 0;
$skipped_sku = 0;
$skipped_vehicle = 0;
$skipped_duplicate = 0;
$fanned_out = 0;
$skipped_details = array();
$touched_products = array();
$index = array( 'sku' => 0, 'year' => 1, 'make' => 2, 'model' => 3, 'submodel' => 4 );
$line_no = 0;
$data_rows = 0;
$header_seen = false;
$chunk_size = 500;
$in_chunk = false;
// Deferred so a 5000-row import costs one transient flush, not one per row.
YMMHERO_DB::defer_flush( true );
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
$line_no++;
if ( self::is_blank_csv_row( $row ) ) {
continue;
}
// The sku column may legitimately be numeric, so unlike the vehicle
// import we can't sniff the header — the format requires one.
if ( ! $header_seen ) {
$header_seen = true;
$index = self::csv_column_index(
$row,
$index,
'assignments',
array( 'sku', 'year', 'make', 'model' )
);
continue;
}
$data_rows++;
if ( ! $in_chunk ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'START TRANSACTION' );
$in_chunk = true;
}
if ( count( $row ) < 4 ) {
$skipped_sku++;
self::add_skipped_detail( $skipped_details, $line_no, 'invalid' );
} else {
$sku = self::csv_value( $row, $index, 'sku' );
$year = absint( self::csv_value( $row, $index, 'year' ) );
$make = self::csv_value( $row, $index, 'make' );
$model = self::csv_value( $row, $index, 'model' );
$submodel = self::csv_value( $row, $index, 'submodel' );
$product_id = $sku !== '' ? wc_get_product_id_by_sku( $sku ) : 0;
if ( ! $product_id ) {
$skipped_sku++;
self::add_skipped_detail( $skipped_details, $line_no, 'sku' );
} elseif ( $year < 1900 || $year > 2100 || $make === '' || $model === '' ) {
$skipped_vehicle++;
self::add_skipped_detail( $skipped_details, $line_no, 'invalid' );
} else {
// A blank sub-model is a wildcard: the row assigns to every
// sub-model of that Y/M/M. Taking only the first match would
// silently bind the product to one arbitrary variant.
// Table collation is case-insensitive, so "toyota" resolves to "Toyota".
$vehicle_ids = YMMHERO_DB::get_vehicle_ids_by_ymm(
$make,
$model,
$year,
'' !== $submodel ? $submodel : null
);
if ( empty( $vehicle_ids ) && $create_missing ) {
// A wildcard row creates the base vehicle only — it must
// not invent sub-model rows the file never named.
$new_id = YMMHERO_DB::get_or_create_vehicle( $year, $make, $model, $submodel );
if ( $new_id ) {
$created_vehicles++;
$vehicle_ids = array( $new_id );
}
}
if ( empty( $vehicle_ids ) ) {
$skipped_vehicle++;
self::add_skipped_detail( $skipped_details, $line_no, 'vehicle' );
} else {
$row_added = 0;
foreach ( $vehicle_ids as $vehicle_id ) {
if ( YMMHERO_DB::add_assignment( $vehicle_id, $product_id ) ) {
$row_added++;
$touched_products[ $product_id ] = true;
}
}
if ( $row_added > 0 ) {
$added += $row_added;
if ( count( $vehicle_ids ) > 1 ) {
$fanned_out++;
}
} else {
$skipped_duplicate++;
}
}
}
}
if ( $in_chunk && $data_rows % $chunk_size === 0 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'COMMIT' );
$in_chunk = false;
}
}
if ( $in_chunk ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache().
$wpdb->query( 'COMMIT' );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the streamed CSV handle opened above.
fclose( $handle );
// Append-only: the pivot is authoritative, so each touched product's meta
// mirror is rebuilt once here rather than on every row that hit it.
foreach ( array_keys( $touched_products ) as $product_id ) {
YMMHERO_DB::resync_product_meta( $product_id );
}
YMMHERO_DB::defer_flush( false );
$redirect = add_query_arg( array(
'message' => 'assignments_imported',
'added' => $added,
'created_vehicles' => $created_vehicles,
'fanned_out' => $fanned_out,
'skipped_sku' => $skipped_sku,
'skipped_vehicle' => $skipped_vehicle,
'skipped_duplicate' => $skipped_duplicate,
'skipped_details' => implode( ',', $skipped_details ),
'elapsed' => round( microtime( true ) - $start_time, 2 ),
), $redirect_base );
wp_safe_redirect( $redirect );
exit;
}
private static function add_skipped_detail( &$details, $line_no, $reason ) {
if ( count( $details ) < 10 ) {
$details[] = absint( $line_no ) . ':' . $reason;
}
}
/**
* Validates an uploaded CSV and returns its tmp path. Redirects and exits on
* any failure, so callers can treat the return value as a usable file.
*/
private function validate_csv_upload( $field, $redirect_base ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
if ( empty( $_FILES[ $field ]['tmp_name'] )
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
|| ! isset( $_FILES[ $field ]['error'], $_FILES[ $field ]['name'] )
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
|| UPLOAD_ERR_OK !== (int) $_FILES[ $field ]['error']
) {
wp_safe_redirect( $redirect_base . '&message=import_no_file' );
exit;
}
// $_FILES is never slashed by WordPress (unlike $_GET/$_POST) — tmp_name is a real
// filesystem path, and wp_unslash() would corrupt it on Windows by stripping backslashes.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
$tmp_name = sanitize_text_field( $_FILES[ $field ]['tmp_name'] );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
$name = sanitize_file_name( $_FILES[ $field ]['name'] );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
$posted_mime = isset( $_FILES[ $field ]['type'] )
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified by the callers before this helper runs.
? sanitize_mime_type( $_FILES[ $field ]['type'] )
: '';
// Only ever read a genuine PHP upload — never an arbitrary server path.
if ( ! is_uploaded_file( $tmp_name ) ) {
wp_safe_redirect( $redirect_base . '&message=import_no_file' );
exit;
}
$allowed_mimes = array( 'text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel' );
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
// wp_check_filetype_and_ext() sniffs the real contents; the posted MIME is attacker-controlled.
$checked = wp_check_filetype_and_ext( $tmp_name, $name, array( 'csv' => 'text/csv' ) );
if ( 'csv' !== $ext
|| ! in_array( $posted_mime, $allowed_mimes, true )
|| empty( $checked['ext'] )
|| 'csv' !== $checked['ext']
) {
wp_safe_redirect( $redirect_base . '&message=import_invalid_type' );
exit;
}
return $tmp_name;
}
private static function is_blank_csv_row( $row ) {
if ( ! is_array( $row ) || array() === $row ) {
return true;
}
foreach ( $row as $cell ) {
if ( '' !== trim( (string) $cell ) ) {
return false;
}
}
return true;
}
private static function is_csv_header_row( $row ) {
$first = isset( $row[0] ) ? trim( (string) $row[0] ) : '';
return ! is_numeric( $first ) || 'year' === strtolower( $first );
}
private static function csv_aliases( $context ) {
$vehicle = array(
'year' => array( 'year' ),
'make' => array( 'make' ),
'model' => array( 'model' ),
'submodel' => array( 'submodel', 'sub_model', 'trim' ),
);
if ( 'assignments' === $context ) {
return array( 'sku' => array( 'sku' ) ) + $vehicle;
}
return $vehicle;
}
/**
* Resolves column positions from a header row, falling back to $positions
* for anything the header doesn't name. A header is only trusted when it
* names every required column — a partial or unrecognised header leaves the
* legacy fixed order intact, so files that imported before the sub-model
* column existed keep importing identically.
*/
private static function csv_column_index( $header, $positions, $context, $required ) {
$aliases = self::csv_aliases( $context );
$mapped = array();
foreach ( (array) $header as $offset => $cell ) {
$key = strtolower( trim( str_replace( array( ' ', '-' ), '_', (string) $cell ) ) );
foreach ( $aliases as $name => $accepted ) {
if ( in_array( $key, $accepted, true ) ) {
$mapped[ $name ] = $offset;
}
}
}
foreach ( $required as $name ) {
if ( ! isset( $mapped[ $name ] ) ) {
return $positions;
}
}
// An optional column absent from a trusted header must stay absent
// rather than fall back to its legacy position.
return array_merge( array_fill_keys( array_keys( $positions ), null ), $mapped );
}
private static function csv_value( $row, $index, $name ) {
if ( ! isset( $index[ $name ] ) || ! isset( $row[ $index[ $name ] ] ) ) {
return '';
}
return sanitize_text_field( trim( (string) $row[ $index[ $name ] ] ) );
}
/**
* Printed by admin/views/partials/app-header.php, not hooked to
* `admin_notices`. A notice queued there paints at the very top of the page
* and is only moved below the shell's header once wp-admin's common.js runs,
* which read as the message jumping down the screen after a bulk action.
*/
public static function show_notices() {
$screen = get_current_screen();
// Settings is here for the setup checklist, which sits in that screen's rail
// and reports back through the same messages.
$ymmhero_screens = array( 'ymmhero-vehicles', 'ymmhero-assignments', 'ymmhero-import', 'ymmhero-settings' );
$on_ymmhero_screen = false;
foreach ( $ymmhero_screens as $ymmhero_screen ) {
if ( $screen && strpos( $screen->id, $ymmhero_screen ) !== false ) {
$on_ymmhero_screen = true;
break;
}
}
if ( ! $on_ymmhero_screen ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$message = isset( $_GET['message'] ) ? sanitize_key( wp_unslash( $_GET['message'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$bulk_count = isset( $_GET['count'] ) ? absint( $_GET['count'] ) : 0;
$notices = array(
'added' => array( 'success', __( 'Vehicle added successfully.', 'ymm-hero' ) ),
'updated' => array( 'success', __( 'Vehicle updated successfully.', 'ymm-hero' ) ),
'deleted' => array( 'success', __( 'Vehicle deleted.', 'ymm-hero' ) ),
'bulk_deleted' => array(
'success',
sprintf(
/* translators: %d: number of vehicles deleted */
_n( '%d vehicle deleted.', '%d vehicles deleted.', $bulk_count, 'ymm-hero' ),
$bulk_count
),
),
'validation_error' => array( 'error', __( 'Please fill in all fields with valid values (year 1900–2100).', 'ymm-hero' ) ),
'not_found' => array( 'error', __( 'Vehicle not found.', 'ymm-hero' ) ),
'assignments_saved' => array( 'success', __( 'Product assignments saved.', 'ymm-hero' ) ),
'import_no_file' => array( 'error', __( 'No file uploaded. Please choose a CSV file and try again.', 'ymm-hero' ) ),
'import_invalid_type' => array( 'error', __( 'Invalid file type. Please upload a .csv file.', 'ymm-hero' ) ),
'sample_loaded' => array( 'success', __( 'Sample vehicles added. Assign a product to one of them, then place the search widget to see it working on your shop. Delete them from the Vehicle DB when you no longer need them.', 'ymm-hero' ) ),
);
if ( $message === 'assignments_imported' ) {
self::show_assignments_import_notice();
return;
}
if ( $message === 'imported' ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$imported = isset( $_GET['imported'] ) ? absint( $_GET['imported'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$skipped_duplicate = isset( $_GET['skipped_duplicate'] ) ? absint( $_GET['skipped_duplicate'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$skipped_invalid = isset( $_GET['skipped_invalid'] ) ? absint( $_GET['skipped_invalid'] ) : 0;
$skipped_total = $skipped_duplicate + $skipped_invalid;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed.
$elapsed = isset( $_GET['elapsed'] ) ? (float) sanitize_text_field( wp_unslash( $_GET['elapsed'] ) ) : null;
$text = sprintf(
/* translators: 1: imported count */
_n( 'Imported %d vehicle.', 'Imported %d vehicles.', $imported, 'ymm-hero' ),
$imported
);
if ( $skipped_total > 0 ) {
$parts = array();
if ( $skipped_duplicate > 0 ) {
$parts[] = sprintf(
/* translators: %d: duplicate count */
_n( '%d duplicate', '%d duplicates', $skipped_duplicate, 'ymm-hero' ),
$skipped_duplicate
);
}
if ( $skipped_invalid > 0 ) {
$parts[] = sprintf(
/* translators: %d: invalid row count */
_n( '%d invalid row', '%d invalid rows', $skipped_invalid, 'ymm-hero' ),
$skipped_invalid
);
}
$text .= ' ' . sprintf(
/* translators: 1: total skipped, 2: breakdown */
_n( 'Skipped %1$d row (%2$s).', 'Skipped %1$d rows (%2$s).', $skipped_total, 'ymm-hero' ),
$skipped_total,
implode( ', ', $parts )
);
}
if ( $elapsed !== null ) {
$text .= ' ' . sprintf(
/* translators: %s: processing time in seconds */
__( 'Processed in %s seconds.', 'ymm-hero' ),
number_format_i18n( $elapsed, 2 )
);
}
$type = $imported > 0 ? 'success' : 'warning';
printf(
'
%s
%s
%s
%s' . esc_html__( 'First skipped rows:', 'ymm-hero' ) . '