Quantities import: upload a CSV, check the preview, then apply the valid rows. * Users need edit_products for the page and edit_product for each product changed. * * @package LayoutFoundryQuantities */ defined( 'ABSPATH' ) || exit; /** * CSV import screen. */ final class LFQTY_Import { const PAGE = 'layout-foundry-quantities-import'; const CAPABILITY = 'edit_products'; /** Previews are kept for one hour. */ const PREVIEW_SECONDS = HOUR_IN_SECONDS; /** * Hooks. */ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_post_lfqty_import_preview', array( __CLASS__, 'handle_preview' ) ); add_action( 'admin_post_lfqty_import_apply', array( __CLASS__, 'handle_apply' ) ); add_action( 'admin_post_lfqty_settings', array( __CLASS__, 'handle_settings' ) ); add_filter( 'plugin_action_links_' . plugin_basename( LFQTY_FILE ), array( __CLASS__, 'action_links' ) ); } /** * Menu entry under Products. */ public static function menu() { add_submenu_page( 'edit.php?post_type=product', __( 'Quantities', 'layout-foundry-quantities' ), __( 'Quantities', 'layout-foundry-quantities' ), self::CAPABILITY, self::PAGE, array( __CLASS__, 'render' ) ); } /** * Plugins screen link. * * @param array $links Links. * @return array */ public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Settings and import', 'layout-foundry-quantities' ) . '' ); return $links; } /** * Page URL with optional query values. * * @param array $args Query values. * @return string */ private static function page_url( array $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::PAGE ), $args ), admin_url( 'edit.php?post_type=product' ) ); } /** * Transient key for a preview, bound to the user who made it. * * @param string $token Random token. * @return string */ private static function preview_key( $token ) { return 'lfqty_preview_' . get_current_user_id() . '_' . $token; } /** * Finds a simple product by SKU or id for LFQTY_Csv::parse(). * * @param string $sku SKU, or "". * @param int $id Product id, or 0. * @return array{id:int,name:string}|null */ public static function lookup( $sku, $id ) { $product_id = '' !== $sku ? wc_get_product_id_by_sku( $sku ) : $id; $product = $product_id ? wc_get_product( $product_id ) : null; if ( ! $product instanceof WC_Product || ! $product->is_type( 'simple' ) || ( $id && $product->get_id() !== $id ) ) { return null; } return array( 'id' => $product->get_id(), 'name' => $product->get_name(), ); } /** * Step 1: reads the uploaded CSV, validates it and stores the preview. */ public static function handle_preview() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to import product settings.', 'layout-foundry-quantities' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'lfqty_import_preview' ); $file = isset( $_FILES['lfqty_csv'] ) && is_array( $_FILES['lfqty_csv'] ) ? $_FILES['lfqty_csv'] : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- the file is checked field by field below. $tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : ''; $name = isset( $file['name'] ) ? sanitize_file_name( (string) $file['name'] ) : ''; if ( ! isset( $file['error'] ) || UPLOAD_ERR_OK !== (int) $file['error'] || ! is_uploaded_file( $tmp ) ) { self::redirect_with_error( __( 'Choose a CSV file to upload.', 'layout-foundry-quantities' ) ); } if ( ! preg_match( '/\.(csv|txt)$/i', $name ) || (int) $file['size'] > LFQTY_Csv::MAX_BYTES ) { self::redirect_with_error( __( 'The file must be a .csv file of 1 MB or less.', 'layout-foundry-quantities' ) ); } $text = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temporary upload of 1 MB or less. if ( false === $text || ! wp_check_invalid_utf8( $text ) ) { self::redirect_with_error( __( 'The file must be saved as UTF-8 text. In Excel, choose "CSV UTF-8".', 'layout-foundry-quantities' ) ); } $result = LFQTY_Csv::parse( $text, array( __CLASS__, 'lookup' ) ); if ( $result['errors'] ) { self::redirect_with_error( $result['errors'][0] ); } $token = wp_generate_password( 20, false ); set_transient( self::preview_key( $token ), array( 'file' => $name, 'rows' => $result['rows'], ), self::PREVIEW_SECONDS ); wp_safe_redirect( self::page_url( array( 'preview' => $token ) ) ); exit; } /** * Step 2: applies the valid rows of a preview. */ public static function handle_apply() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to import product settings.', 'layout-foundry-quantities' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'lfqty_import_apply' ); $token = isset( $_POST['preview'] ) ? sanitize_key( wp_unslash( $_POST['preview'] ) ) : ''; $preview = '' !== $token ? get_transient( self::preview_key( $token ) ) : false; if ( ! is_array( $preview ) ) { self::redirect_with_error( __( 'The preview has expired. Upload the file again.', 'layout-foundry-quantities' ) ); } delete_transient( self::preview_key( $token ) ); $applied = 0; $skipped = 0; foreach ( $preview['rows'] as $row ) { $product = $row['product_id'] ? wc_get_product( $row['product_id'] ) : null; if ( $row['errors'] || ! $product || ! current_user_can( 'edit_product', $product->get_id() ) ) { ++$skipped; continue; } // Validate again: the preview was stored for up to an hour. $checked = LFQTY_Fields::validate( $row['values'] ); if ( $checked['errors'] ) { ++$skipped; continue; } LFQTY_Product::store( $product, $checked['values'] ); $product->save(); ++$applied; } wp_safe_redirect( self::page_url( array( 'applied' => $applied, 'skipped' => $skipped, ) ) ); exit; } /** * Saves the placement setting. */ public static function handle_settings() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'layout-foundry-quantities' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'lfqty_settings' ); update_option( LFQTY_Product::OPTION_AUTO, isset( $_POST['lfqty_auto_place'] ) ? 'yes' : 'no' ); wp_safe_redirect( self::page_url( array( 'saved' => 1 ) ) ); exit; } /** * Sends the user back to the page with one error message, kept briefly for this user. * * @param string $message Message. */ private static function redirect_with_error( $message ) { set_transient( 'lfqty_error_' . get_current_user_id(), $message, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( self::page_url( array( 'error' => 1 ) ) ); exit; } /** * Page output. */ public static function render() { if ( ! current_user_can( self::CAPABILITY ) ) { return; } // Read-only display values from the redirects above; every action is nonce-checked in its handler. // phpcs:disable WordPress.Security.NonceVerification.Recommended $token = isset( $_GET['preview'] ) ? sanitize_key( wp_unslash( $_GET['preview'] ) ) : ''; $applied = isset( $_GET['applied'] ) ? absint( $_GET['applied'] ) : null; $skipped = isset( $_GET['skipped'] ) ? absint( $_GET['skipped'] ) : 0; $saved = isset( $_GET['saved'] ); $error = isset( $_GET['error'] ) ? get_transient( 'lfqty_error_' . get_current_user_id() ) : false; // phpcs:enable WordPress.Security.NonceVerification.Recommended $preview = '' !== $token ? get_transient( self::preview_key( $token ) ) : false; $columns = array_merge( array( 'sku', 'product_id' ), array_keys( LFQTY_Fields::definitions() ) ); ?>

', array_map( 'esc_html', $columns ) ), array( 'wbr' => array() ) ); ?>

sku,product,units,tile_size,pieces_per_pack,joint,allowance
GREY-3060,tiles,cm,30x60,8,3,10
OAK-PLANK,flooring,cm,,,,7


0 ) : ?>
$value ) { if ( '' !== $value ) { $parts[] = $key . '=' . $value; } } return implode( '; ', $parts ); } }