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

', esc_attr( $type ), esc_html( $text ) ); return; } if ( isset( $notices[ $message ] ) ) { list( $type, $text ) = $notices[ $message ]; printf( '

%s

', esc_attr( $type ), esc_html( $text ) ); } } private static function show_assignments_import_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $added = isset( $_GET['added'] ) ? absint( $_GET['added'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $created_vehicles = isset( $_GET['created_vehicles'] ) ? absint( $_GET['created_vehicles'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $fanned_out = isset( $_GET['fanned_out'] ) ? absint( $_GET['fanned_out'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $skipped_sku = isset( $_GET['skipped_sku'] ) ? absint( $_GET['skipped_sku'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $skipped_vehicle = isset( $_GET['skipped_vehicle'] ) ? absint( $_GET['skipped_vehicle'] ) : 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; $skipped_total = $skipped_sku + $skipped_vehicle + $skipped_duplicate; $text = sprintf( /* translators: %d: number of assignments added */ _n( 'Added %d assignment.', 'Added %d assignments.', $added, 'ymm-hero' ), $added ); if ( $created_vehicles > 0 ) { $text .= ' ' . sprintf( /* translators: %d: number of vehicles created */ _n( 'Created %d vehicle.', 'Created %d vehicles.', $created_vehicles, 'ymm-hero' ), $created_vehicles ); } if ( $fanned_out > 0 ) { $text .= ' ' . sprintf( /* translators: %d: number of rows that matched several sub-models */ _n( '%d row left the sub-model blank and was applied to every matching sub-model.', '%d rows left the sub-model blank and were applied to every matching sub-model.', $fanned_out, 'ymm-hero' ), $fanned_out ); } if ( $skipped_total > 0 ) { $parts = array(); if ( $skipped_sku > 0 ) { $parts[] = sprintf( /* translators: %d: number of unknown SKUs */ _n( '%d unknown SKU', '%d unknown SKUs', $skipped_sku, 'ymm-hero' ), $skipped_sku ); } if ( $skipped_vehicle > 0 ) { $parts[] = sprintf( /* translators: %d: number of missing vehicles */ _n( '%d missing vehicle', '%d missing vehicles', $skipped_vehicle, 'ymm-hero' ), $skipped_vehicle ); } if ( $skipped_duplicate > 0 ) { $parts[] = sprintf( /* translators: %d: duplicate count */ _n( '%d duplicate', '%d duplicates', $skipped_duplicate, 'ymm-hero' ), $skipped_duplicate ); } $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 ) ); } $type = $added > 0 ? 'success' : 'warning'; printf( '

%s

%s
', esc_attr( $type ), esc_html( $text ), wp_kses_post( self::format_skipped_details() ) ); } private static function format_skipped_details() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. $raw = isset( $_GET['skipped_details'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination/filter parameter, sanitized at the point of use; no state is changed. ? sanitize_text_field( wp_unslash( $_GET['skipped_details'] ) ) : ''; if ( '' === $raw ) { return ''; } $reasons = array( 'sku' => __( 'unknown SKU', 'ymm-hero' ), 'vehicle' => __( 'vehicle not found', 'ymm-hero' ), 'invalid' => __( 'invalid row', 'ymm-hero' ), ); $items = array(); foreach ( explode( ',', $raw ) as $detail ) { $parts = explode( ':', $detail ); if ( count( $parts ) !== 2 || ! isset( $reasons[ $parts[1] ] ) ) { continue; } $items[] = '
  • ' . esc_html( sprintf( /* translators: 1: CSV line number, 2: reason the row was skipped */ __( 'Row %1$d — %2$s', 'ymm-hero' ), absint( $parts[0] ), $reasons[ $parts[1] ] ) ) . '
  • '; } if ( empty( $items ) ) { return ''; } return '

    ' . esc_html__( 'First skipped rows:', 'ymm-hero' ) . '

    '; } public function handle_ajax_quick_edit() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( __( 'Permission denied.', 'ymm-hero' ) ); } check_ajax_referer( 'ymmhero_quick_edit_vehicle', 'nonce' ); $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'] ) ) : ''; if ( ! $id || $year < 1900 || $year > 2100 || $make === '' || $model === '' ) { wp_send_json_error( __( 'Please fill in all fields with valid values (year 1900–2100).', 'ymm-hero' ) ); } // Passing null leaves the column alone, so a deactivated add-on can never // blank out sub-models that are still stored in free's table. $submodel = null; if ( self::submodel_supported() && isset( $_POST['submodel'] ) ) { $submodel = substr( sanitize_text_field( wp_unslash( $_POST['submodel'] ) ), 0, 100 ); } if ( false === YMMHERO_DB::update_vehicle( $id, $year, $make, $model, $submodel ) ) { wp_send_json_error( __( 'Update failed — that vehicle may already exist.', 'ymm-hero' ) ); } $response = array( 'id' => $id, 'year' => $year, 'make' => $make, 'model' => $model, ); if ( null !== $submodel ) { $response['submodel'] = $submodel; } wp_send_json_success( $response ); } /** * The list screen holds every vehicle in the browser, so it cannot carry the * per-id nonce the admin-post delete uses — one nonce for the screen, as quick * edit already does. */ public function handle_ajax_delete_vehicle() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( __( 'Permission denied.', 'ymm-hero' ) ); } check_ajax_referer( 'ymmhero_delete_vehicle_row', 'nonce' ); $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; if ( ! $id || YMMHERO_DB::delete_vehicle( $id ) === false ) { wp_send_json_error( __( 'Delete failed. Please try again.', 'ymm-hero' ) ); } wp_send_json_success( array( 'id' => $id ) ); } /** * YMMHERO_VERSION only moves on release, so an edited CSS/JS file keeps serving from * the browser cache until then. YMMHERO_Public::asset_url() already keys the front-end * assets on file mtime; admin assets go through the same helper rather than * inventing a second scheme (it also picks up a .min build if one is ever added). */ private static function asset_version( $relative_path ) { list( , $version ) = YMMHERO_Public::asset_url( $relative_path ); return $version; } /** * Paints the top-level menu mark as a CSS mask rather than an . * * The ::before is the same element core colours for dashicon menu items, so * `currentColor` picks up idle/hover/current — and any admin colour scheme — * for free. Loaded on every admin page because the sidebar is always there. */ public function enqueue_menu_icon_style() { $icon = YMMHERO_PLUGIN_URL . 'admin/assets/sidebar-icon.png'; wp_register_style( 'ymmhero-menu-icon', false, array(), YMMHERO_VERSION ); wp_enqueue_style( 'ymmhero-menu-icon' ); wp_add_inline_style( 'ymmhero-menu-icon', sprintf( '#adminmenu #toplevel_page_%1$s .wp-menu-image::before{content:"";display:block;width:20px;height:20px;margin:0 auto;background-color:currentColor;-webkit-mask:url(\'%2$s\') no-repeat center / 20px 20px;mask:url(\'%2$s\') no-repeat center / 20px 20px;}', esc_attr( self::MENU_SLUG ), esc_url_raw( $icon ) ) ); } public function enqueue_assets( $hook ) { $allowed_hooks = array( $this->vehicles_hook, $this->assignments_hook, $this->settings_hook, $this->import_hook, $this->addons_hook ); if ( ! in_array( $hook, $allowed_hooks, true ) ) { return; } wp_enqueue_style( 'ymmhero-admin', YMMHERO_PLUGIN_URL . 'admin/assets/admin.css', array(), self::asset_version( 'admin/assets/admin.css' ) ); $script_deps = array( 'jquery' ); if ( $this->settings_hook === $hook ) { wp_enqueue_style( 'wp-color-picker' ); $script_deps[] = 'wp-color-picker'; } wp_enqueue_script( 'ymmhero-admin', YMMHERO_PLUGIN_URL . 'admin/assets/admin.js', $script_deps, self::asset_version( 'admin/assets/admin.js' ), true ); $this->enqueue_assignments_assets( $hook ); $this->enqueue_vehicles_assets( $hook ); } /** * The vehicle list renders client-side; the add/edit sub-screens are plain * forms and must not pull the list bundle. */ private function enqueue_vehicles_assets( $hook ) { if ( $this->vehicles_hook !== $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( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( 'add' === $action || 'edit' === $action ) { return; } wp_enqueue_script( 'ymmhero-vehicles', YMMHERO_PLUGIN_URL . 'admin/assets/vehicles.js', // ymmhero-admin defines window.ymmheroNotice, which the row actions report through. array( 'ymmhero-admin' ), self::asset_version( 'admin/assets/vehicles.js' ), true ); wp_add_inline_script( 'ymmhero-vehicles', // JSON_HEX_TAG: make/model is admin-entered free text, and a literal // "" in the data would close the tag. 'window.ymmheroVehiclesData = ' . wp_json_encode( YMMHERO_DB::get_vehicle_picker_rows(), JSON_HEX_TAG ) . ';', 'before' ); wp_localize_script( 'ymmhero-vehicles', 'ymmheroVehicles', array( 'submodelSupported' => self::submodel_supported(), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'quickEditNonce' => wp_create_nonce( 'ymmhero_quick_edit_vehicle' ), 'deleteNonce' => wp_create_nonce( 'ymmhero_delete_vehicle_row' ), 'addUrl' => admin_url( 'admin.php?page=ymmhero-vehicles&action=add' ), 'importUrl' => admin_url( 'admin.php?page=ymmhero-import' ), 'sampleUrl' => YMMHERO_Setup::load_sample_url(), 'perPage' => 10, 'rowsLimit' => YMMHERO_DB::ASSIGNMENT_ROWS_LIMIT, 'i18n' => array( 'emptyTitle' => __( 'No vehicles yet', 'ymm-hero' ), 'emptyText' => __( 'Add the years, makes and models your customers drive. You can import a CSV instead of adding them one at a time.', 'ymm-hero' ), 'emptyAction' => __( 'Add vehicle', 'ymm-hero' ), 'emptyImport' => __( 'Import CSV', 'ymm-hero' ), 'emptySample' => __( 'Load sample vehicles', 'ymm-hero' ), 'noMatchTitle' => __( 'No vehicles match', 'ymm-hero' ), 'noMatchText' => __( 'Nothing here fits the current filters. Widen them to see more vehicles.', 'ymm-hero' ), 'clearFilters' => __( 'Clear filters', 'ymm-hero' ), 'statVehicles' => __( 'Vehicles', 'ymm-hero' ), 'statMakes' => __( 'Makes', 'ymm-hero' ), 'statModels' => __( 'Models', 'ymm-hero' ), 'edit' => __( 'Edit', 'ymm-hero' ), 'deleteLabel' => __( 'Delete', 'ymm-hero' ), 'confirmDelete' => __( 'Delete this vehicle?', 'ymm-hero' ), 'deletedMsg' => __( 'Vehicle deleted.', 'ymm-hero' ), 'savedMsg' => __( 'Vehicle updated.', 'ymm-hero' ), 'dismissNotice' => __( 'Dismiss this notice', 'ymm-hero' ), 'update' => __( 'Update', 'ymm-hero' ), 'cancel' => __( 'Cancel', 'ymm-hero' ), 'saving' => __( 'Saving…', 'ymm-hero' ), 'errorMsg' => __( 'Save failed. Please try again.', 'ymm-hero' ), 'invalidVehicle' => __( 'Please fill in all fields with valid values (year 1900–2100).', 'ymm-hero' ), 'selectAll' => __( 'Select all vehicles', 'ymm-hero' ), 'selectVehicle' => __( 'Select vehicle', 'ymm-hero' ), 'anyYear' => __( 'All years', 'ymm-hero' ), 'anyMake' => __( 'All makes', 'ymm-hero' ), 'anyModel' => __( 'All models', 'ymm-hero' ), /* translators: 1: rows on this page, 2: rows matching the filters */ 'showing' => __( 'Showing %1$s of %2$s', 'ymm-hero' ), 'year' => __( 'Year', 'ymm-hero' ), 'make' => __( 'Make', 'ymm-hero' ), 'model' => __( 'Model', 'ymm-hero' ), 'submodel' => __( 'Sub-model', 'ymm-hero' ), 'actions' => __( 'Actions', 'ymm-hero' ), /* translators: 1: current page, 2: total pages */ 'pageOf' => __( '%1$s of %2$s', 'ymm-hero' ), 'prevPage' => __( 'Previous page', 'ymm-hero' ), 'nextPage' => __( 'Next page', 'ymm-hero' ), 'sortAsc' => __( 'Sort ascending', 'ymm-hero' ), 'sortDesc' => __( 'Sort descending', 'ymm-hero' ), /* translators: %s: the row limit, e.g. "10,000" */ 'truncated' => __( 'Showing the first %s vehicles only. Use Import to narrow your catalogue.', 'ymm-hero' ), ), ) ); } /** * The assignments list renders client-side; the per-vehicle "assign" sub-screen * is a plain form and must not pull the list bundle. */ private function enqueue_assignments_assets( $hook ) { if ( $this->assignments_hook !== $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 ) { return; } wp_enqueue_script( 'ymmhero-assignments', YMMHERO_PLUGIN_URL . 'admin/assets/assignments.js', array(), self::asset_version( 'admin/assets/assignments.js' ), true ); // Printed inline rather than fetched: the rows are the page's own content, and // a second trip to admin-ajax.php costs a full WP+WooCommerce bootstrap (~3s // here without OPcache) to move ~34KB the page render could have carried for // free. Inlining is what removes the "Loading vehicles…" flash. wp_add_inline_script( 'ymmhero-assignments', // JSON_HEX_TAG: a make/model is admin-entered free text, and inside a // " in the data would close the tag. 'window.ymmheroAssignmentsData = ' . wp_json_encode( YMMHERO_DB::get_assignment_rows(), JSON_HEX_TAG ) . ';', 'before' ); wp_localize_script( 'ymmhero-assignments', 'ymmheroAssignments', array( 'submodelSupported' => self::submodel_supported(), 'assignUrl' => admin_url( 'admin.php?page=ymmhero-assignments&action=assign&vehicle_id=' ), 'vehiclesUrl' => admin_url( 'admin.php?page=ymmhero-vehicles&action=add' ), 'importUrl' => admin_url( 'admin.php?page=ymmhero-import' ), 'sampleUrl' => YMMHERO_Setup::load_sample_url(), 'perPage' => 10, 'rowsLimit' => YMMHERO_DB::ASSIGNMENT_ROWS_LIMIT, 'i18n' => array( 'emptyTitle' => __( 'No vehicles yet', 'ymm-hero' ), 'emptyText' => __( 'Products are assigned to vehicles, so the vehicle database comes first. Add one to get started.', 'ymm-hero' ), 'emptyAction' => __( 'Add vehicle', 'ymm-hero' ), 'emptyImport' => __( 'Import CSV', 'ymm-hero' ), 'emptySample' => __( 'Load sample vehicles', 'ymm-hero' ), 'noMatchTitle' => __( 'No vehicles match', 'ymm-hero' ), 'noMatchText' => __( 'Nothing here fits the current filters. Widen them to see more vehicles.', 'ymm-hero' ), 'clearFilters' => __( 'Clear filters', 'ymm-hero' ), 'noProducts' => __( 'None', 'ymm-hero' ), 'assign' => __( 'Assign', 'ymm-hero' ), 'edit' => __( 'Edit', 'ymm-hero' ), 'anyYear' => __( 'All years', 'ymm-hero' ), 'anyMake' => __( 'All makes', 'ymm-hero' ), 'anyModel' => __( 'All models', 'ymm-hero' ), 'all' => __( 'All', 'ymm-hero' ), 'assigned' => __( 'Assigned', 'ymm-hero' ), 'notAssigned' => __( 'Not Assigned', 'ymm-hero' ), 'searchLabel' => __( 'Search', 'ymm-hero' ), 'searchPlaceholder' => __( 'Year, make or model…', 'ymm-hero' ), /* translators: 1: rows on this page, 2: rows matching the filters */ 'showing' => __( 'Showing %1$s of %2$s', 'ymm-hero' ), 'year' => __( 'Year', 'ymm-hero' ), 'make' => __( 'Make', 'ymm-hero' ), 'model' => __( 'Model', 'ymm-hero' ), 'submodel' => __( 'Sub-model', 'ymm-hero' ), 'products' => __( 'Products', 'ymm-hero' ), 'actions' => __( 'Actions', 'ymm-hero' ), /* translators: 1: current page, 2: total pages */ 'pageOf' => __( '%1$s of %2$s', 'ymm-hero' ), 'prevPage' => __( 'Previous page', 'ymm-hero' ), 'nextPage' => __( 'Next page', 'ymm-hero' ), 'sortAsc' => __( 'Sort ascending', 'ymm-hero' ), 'sortDesc' => __( 'Sort descending', 'ymm-hero' ), /* translators: %s: number of products */ 'productsOne' => __( '%s product', 'ymm-hero' ), /* translators: %s: number of products */ 'productsMany' => __( '%s products', 'ymm-hero' ), /* translators: %s: the row limit, e.g. "10,000" */ 'truncated' => __( 'Showing the first %s vehicles only. Use Import or the Vehicles screen to narrow your catalogue.', 'ymm-hero' ), ), ) ); } }