. Pure PHP apart from translation, so it is unit-tested; the product * screen and the CSV import both use validate(). * * @package LayoutFoundryQuantities */ defined( 'ABSPATH' ) || exit; /** * Product fields. */ final class LFQTY_Fields { /** Prefix of every product meta key. */ const META_PREFIX = '_lfqty_'; /** * Field rules. type: enum (with options), size, int (min, max) or decimal (min, max). * * Fields for paint, decking, fencing, gravel and turf come after the first ones, so CSV files and * stored settings from before they existed read the same. Their values are in the shop's units, so * each also has a planner range (see planner_ranges()). * * @return array */ public static function definitions() { return array( 'enabled' => array( 'type' => 'enum', 'options' => array( 'yes', 'no' ), 'default' => 'no', ), 'product' => array( 'type' => 'enum', 'options' => array( 'tiles', 'flooring', 'paving', 'wallpaper', 'paint', 'decking', 'fencing', 'gravel', 'turf' ), 'default' => 'tiles', ), 'units' => array( 'type' => 'enum', 'options' => array( 'cm', 'in' ), 'default' => 'cm', ), 'tile_size' => array( 'type' => 'size' ), 'pieces_per_pack' => array( 'type' => 'int', 'min' => 1, 'max' => 10000, ), 'coverage' => array( 'type' => 'decimal', 'min' => 0.01, 'max' => 10000, ), 'joint' => array( 'type' => 'decimal', 'min' => 0, 'max' => 100, ), 'pattern' => array( 'type' => 'enum', 'options' => array( 'straight', 'half-bond' ), 'default' => 'straight', ), 'allowance' => array( 'type' => 'int', 'min' => 0, 'max' => 100, ), 'roll_width' => array( 'type' => 'decimal', 'min' => 1, 'max' => 1000, ), 'roll_length' => array( 'type' => 'decimal', 'min' => 0.1, 'max' => 1000, ), 'pattern_repeat' => array( 'type' => 'decimal', 'min' => 0, 'max' => 1000, ), 'match' => array( 'type' => 'enum', 'options' => array( 'straight', 'half-drop' ), 'default' => 'straight', ), 'spread_rate' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), 'coats' => array( 'type' => 'int', 'min' => 1, 'max' => 10, ), 'tin_size' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), 'joist_spacing' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), 'panel_width' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), 'post_width' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), 'density' => array( 'type' => 'decimal', 'min' => 0.1, 'max' => 5, ), 'bag_size' => array( 'type' => 'decimal', 'min' => 0, 'max' => 99999, ), ); } /** * What the planner accepts for the fields whose unit depends on the shop's units: the factor from the * metric unit and from the imperial unit to the planner's whole unit, and the planner's range in that * unit. Tin sizes are litres or US gallons (to ml), bag weights kg or lb (to g), coverage per litre m² * per litre or sq ft per US gallon (to mm² per litre), and lengths cm or in (to mm). * * @return array */ public static function planner_ranges() { return array( 'spread_rate' => array( 1000000, 92903.04 / 3.785411784, 100000, 100000000 ), 'tin_size' => array( 1000, 3785.411784, 50, 100000 ), 'joist_spacing' => array( 10, 25.4, 100, 2000 ), 'panel_width' => array( 10, 25.4, 300, 5000 ), 'post_width' => array( 10, 25.4, 20, 300 ), 'bag_size' => array( 1000, 453.59237, 100, 5000000 ), ); } /** * Field labels for messages and the CSV preview. * * @return array */ public static function labels() { return array( 'enabled' => __( 'Quantity planner', 'layout-foundry-quantities' ), 'product' => __( 'Product type', 'layout-foundry-quantities' ), 'units' => __( 'Units', 'layout-foundry-quantities' ), 'tile_size' => __( 'Piece size', 'layout-foundry-quantities' ), 'pieces_per_pack' => __( 'Pieces per pack', 'layout-foundry-quantities' ), 'coverage' => __( 'Coverage per pack', 'layout-foundry-quantities' ), 'joint' => __( 'Joint', 'layout-foundry-quantities' ), 'pattern' => __( 'Laying pattern', 'layout-foundry-quantities' ), 'allowance' => __( 'Starting allowance', 'layout-foundry-quantities' ), 'roll_width' => __( 'Roll width', 'layout-foundry-quantities' ), 'roll_length' => __( 'Roll length', 'layout-foundry-quantities' ), 'pattern_repeat' => __( 'Pattern repeat', 'layout-foundry-quantities' ), 'match' => __( 'Pattern match', 'layout-foundry-quantities' ), 'spread_rate' => __( 'Paint coverage', 'layout-foundry-quantities' ), 'coats' => __( 'Coats', 'layout-foundry-quantities' ), 'tin_size' => __( 'Tin size', 'layout-foundry-quantities' ), 'joist_spacing' => __( 'Joist spacing', 'layout-foundry-quantities' ), 'panel_width' => __( 'Panel width', 'layout-foundry-quantities' ), 'post_width' => __( 'Post width', 'layout-foundry-quantities' ), 'density' => __( 'Density', 'layout-foundry-quantities' ), 'bag_size' => __( 'Bag weight', 'layout-foundry-quantities' ), ); } /** * Validates raw values. Blank optional fields become "". Enum fields that are blank take their default. * * @param array $raw Values keyed by field name (strings). * @return array{values:array,errors:string[]} */ public static function validate( array $raw ) { $labels = self::labels(); $values = array(); $errors = array(); foreach ( self::definitions() as $key => $rule ) { $value = isset( $raw[ $key ] ) && is_scalar( $raw[ $key ] ) ? trim( (string) $raw[ $key ] ) : ''; $value = strtolower( $value ); if ( '' === $value ) { $values[ $key ] = $rule['default'] ?? ''; continue; } $clean = self::clean_value( $value, $rule ); if ( null === $clean ) { $errors[] = self::rule_message( $labels[ $key ], $rule, $value ); $values[ $key ] = $rule['default'] ?? ''; continue; } $values[ $key ] = $clean; } // Values in the shop's units must fit what the planner accepts once converted. $imperial = 'in' === $values['units']; foreach ( self::planner_ranges() as $key => $range ) { if ( '' === $values[ $key ] ) { continue; } $factor = $imperial ? $range[1] : $range[0]; $whole = (int) round( (float) $values[ $key ] * $factor ); if ( $whole < $range[2] || $whole > $range[3] ) { /* translators: 1: field label, 2: value given, 3: lowest, 4: highest. */ $errors[] = sprintf( __( '%1$s "%2$s" must be a number from %3$s to %4$s.', 'layout-foundry-quantities' ), $labels[ $key ], $values[ $key ], self::number_text( ceil( $range[2] / $factor * 1000 ) / 1000 ), self::number_text( floor( $range[3] / $factor * 1000 ) / 1000 ) ); $values[ $key ] = ''; } } // A planner that is switched on needs enough information to count what to buy. if ( 'yes' === $values['enabled'] ) { switch ( $values['product'] ) { case 'wallpaper': if ( '' === $values['roll_width'] || '' === $values['roll_length'] ) { $errors[] = __( 'Wallpaper needs a roll width and a roll length.', 'layout-foundry-quantities' ); } break; case 'paint': if ( '' === $values['spread_rate'] || '' === $values['tin_size'] ) { $errors[] = __( 'Paint needs a paint coverage and a tin size.', 'layout-foundry-quantities' ); } break; case 'decking': if ( '' === $values['tile_size'] ) { $errors[] = __( 'Decking needs a piece size: the board\'s covered width x length.', 'layout-foundry-quantities' ); } break; case 'fencing': if ( '' === $values['panel_width'] ) { $errors[] = __( 'Fencing needs a panel width.', 'layout-foundry-quantities' ); } break; case 'gravel': if ( '' === $values['density'] || '' === $values['bag_size'] ) { $errors[] = __( 'Gravel needs a density and a bag weight.', 'layout-foundry-quantities' ); } break; case 'turf': if ( ( '' === $values['roll_width'] || '' === $values['roll_length'] ) && '' === $values['coverage'] ) { $errors[] = __( 'Turf needs a roll width and a roll length, or a coverage per roll.', 'layout-foundry-quantities' ); } break; default: if ( ( '' === $values['tile_size'] || '' === $values['pieces_per_pack'] ) && '' === $values['coverage'] ) { $errors[] = __( 'Tiles, flooring and paving need a piece size with pieces per pack, or a coverage per pack.', 'layout-foundry-quantities' ); } } } return array( 'values' => $values, 'errors' => $errors, ); } /** * One value against its rule. * * @param string $value Lower-case trimmed value. * @param array $rule Rule. * @return string|null Normalised value, or null when invalid. */ private static function clean_value( $value, array $rule ) { switch ( $rule['type'] ) { case 'enum': return in_array( $value, $rule['options'], true ) ? $value : null; case 'size': // "30x60", "30 × 60", "12x24". if ( ! preg_match( '/^(\d{1,4}(?:[.,]\d{1,2})?)\s*(?:x|×|\*)\s*(\d{1,4}(?:[.,]\d{1,2})?)$/u', $value, $m ) ) { return null; } $a = (float) str_replace( ',', '.', $m[1] ); $b = (float) str_replace( ',', '.', $m[2] ); return $a > 0 && $b > 0 ? self::number_text( $a ) . 'x' . self::number_text( $b ) : null; case 'int': if ( ! preg_match( '/^\d{1,6}$/', $value ) ) { return null; } $number = (int) $value; return $number >= $rule['min'] && $number <= $rule['max'] ? (string) $number : null; case 'decimal': $value = str_replace( ',', '.', $value ); if ( ! preg_match( '/^\d{1,5}(\.\d{1,3})?$/', $value ) ) { return null; } $number = (float) $value; return $number >= $rule['min'] && $number <= $rule['max'] ? self::number_text( $number ) : null; } return null; } /** * Message for a value that does not fit its rule. * * @param string $label Field label. * @param array $rule Rule. * @param string $value Value given. * @return string */ private static function rule_message( $label, array $rule, $value ) { switch ( $rule['type'] ) { case 'enum': /* translators: 1: field label, 2: value given, 3: allowed values. */ return sprintf( __( '%1$s "%2$s" must be one of: %3$s.', 'layout-foundry-quantities' ), $label, $value, implode( ', ', $rule['options'] ) ); case 'size': /* translators: 1: field label, 2: value given. */ return sprintf( __( '%1$s "%2$s" must look like 30x60.', 'layout-foundry-quantities' ), $label, $value ); case 'int': /* translators: 1: field label, 2: value given, 3: lowest, 4: highest. */ return sprintf( __( '%1$s "%2$s" must be a whole number from %3$s to %4$s.', 'layout-foundry-quantities' ), $label, $value, $rule['min'], $rule['max'] ); default: /* translators: 1: field label, 2: value given, 3: lowest, 4: highest. */ return sprintf( __( '%1$s "%2$s" must be a number from %3$s to %4$s.', 'layout-foundry-quantities' ), $label, $value, $rule['min'], $rule['max'] ); } } /** * A number without trailing zeros: 2.5, 30, 0.125. * * @param float $number Number. * @return string */ private static function number_text( $number ) { $text = rtrim( rtrim( number_format( (float) $number, 3, '.', '' ), '0' ), '.' ); return '' === $text ? '0' : $text; } /** * Attributes for paint, decking, fencing, gravel and turf. Every value goes with its unit where the * shop's units change what a bare number means (tins, bags, coverage). * * @param array $attrs Attributes so far (product, units, product name). * @param array $values Validated values. * @return array */ private static function material_attributes( array $attrs, array $values ) { $in = 'in' === $values['units']; $optional = static function ( $attr, $key ) use ( &$attrs, $values ) { if ( '' !== $values[ $key ] ) { $attrs[ $attr ] = $values[ $key ]; } }; switch ( $values['product'] ) { case 'paint': $attrs['spread-rate'] = $values['spread_rate'] . ( $in ? ' sq ft/gal' : ' m2/l' ); $optional( 'coats', 'coats' ); $attrs['tin-sizes'] = $values['tin_size'] . ( $in ? 'gal' : 'L' ); break; case 'decking': $attrs['tile-size'] = $values['tile_size']; $optional( 'pieces-per-pack', 'pieces_per_pack' ); $optional( 'joint', 'joint' ); $optional( 'joist-spacing', 'joist_spacing' ); $optional( 'allowance', 'allowance' ); break; case 'fencing': $attrs['panel-width'] = $values['panel_width']; $optional( 'post-width', 'post_width' ); break; case 'gravel': $attrs['density'] = $values['density']; $attrs['bag-size'] = $values['bag_size'] . ( $in ? 'lb' : 'kg' ); $optional( 'allowance', 'allowance' ); break; case 'turf': if ( '' !== $values['roll_width'] && '' !== $values['roll_length'] ) { $attrs['roll-width'] = $values['roll_width']; $attrs['roll-length'] = $values['roll_length']; } if ( '' !== $values['coverage'] ) { $attrs['coverage'] = $values['coverage'] . ( $in ? ' sq ft' : ' m2' ); } $optional( 'allowance', 'allowance' ); break; } $attrs['action-label'] = __( 'Add {quantity} {unit} to basket', 'layout-foundry-quantities' ); return $attrs; } /** * Attributes for , as plain strings. The caller escapes them. * * @param array $values Validated values. * @param string $product_name Product name shown in the planner's intro line. * @return array */ public static function element_attributes( array $values, $product_name ) { $in = 'in' === $values['units']; $attrs = array( 'product' => $values['product'], 'units' => $values['units'], 'product-name' => (string) $product_name, ); if ( 'wallpaper' === $values['product'] ) { $attrs['roll-width'] = $values['roll_width']; $attrs['roll-length'] = $values['roll_length']; if ( '' !== $values['pattern_repeat'] ) { $attrs['pattern-repeat'] = $values['pattern_repeat']; } $attrs['match'] = $values['match']; $attrs['action-label'] = __( 'Add {quantity} {unit} to basket', 'layout-foundry-quantities' ); return $attrs; } if ( in_array( $values['product'], array( 'paint', 'decking', 'fencing', 'gravel', 'turf' ), true ) ) { return self::material_attributes( $attrs, $values ); } if ( '' !== $values['tile_size'] ) { $attrs['tile-size'] = $values['tile_size']; } if ( '' !== $values['pieces_per_pack'] ) { $attrs['pieces-per-pack'] = $values['pieces_per_pack']; } if ( '' !== $values['coverage'] ) { $attrs['coverage'] = $values['coverage'] . ( $in ? ' sq ft' : ' m2' ); } if ( '' !== $values['joint'] ) { $attrs['joint'] = $values['joint']; } $attrs['pattern'] = $values['pattern']; if ( '' !== $values['allowance'] ) { $attrs['allowance'] = $values['allowance']; } $attrs['action-label'] = __( 'Add {quantity} {unit} to basket', 'layout-foundry-quantities' ); return $attrs; } }