field, 'label' => label ]. */ const SETTING = 'item_fields'; /** * Filter: the detail lines of an item ( string[] $details, WC_Order_Item_Product $item, Settings $settings ). * Returns the lines, one string each. */ const FILTER = 'careback_item_details'; /** Section of the settings screen the row is printed in. */ const SECTION = 'variation'; /** The most fields a shop picks. */ const MAX_FIELDS = 10; /** The longest label. */ const LABEL_MAX = 60; /** The longest field name (a meta key's column). */ const KEY_MAX = 255; /** Empty rows printed under the shop's fields. */ const BLANK_ROWS = 2; /** How many of the latest orders the offered fields are read from. */ const RECENT_ORDERS = 20; /** Field of the form that says the rows were on it. */ const SHOWN = 'careback_item_fields_shown'; /** Field of the form holding the rows. */ const POSTED = 'careback_item_fields'; /** Prefix of the short-lived option holding the rows and messages of a refused save, per user. */ const ERRORS = 'careback_item_fields_errors_'; /** The keys of the custom mode of earlier releases (Settings::RETIRED). */ const RETIRED = array( 'variation_mode', 'variation_ext_key', 'variation_ext_label', 'variation_int_taxonomy', 'variation_int_label' ); /** * Hooks the row and its save. * * @return void */ public static function init() { add_filter( Admin::FIELDS_FILTER, array( __CLASS__, 'fields' ), 10, 2 ); add_filter( Admin::SAVE_FILTER, array( __CLASS__, 'save' ), 10, 4 ); } /** * Whether a field is hidden: its key starts with "_", as WooCommerce's own * bookkeeping and most plugins' private values do. * * @param string $key Field. * @return bool */ public static function hidden( $key ) { return '_' === substr( (string) $key, 0, 1 ); } /** * A list of fields made safe to use: a row with a field that is not hidden, * each field once, at most MAX_FIELDS, labels trimmed. * * @param mixed $rows Stored or given rows. * @return array */ public static function clean( $rows ) { $out = array(); $seen = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( ! is_array( $row ) || ! isset( $row['key'] ) || ! is_scalar( $row['key'] ) ) { continue; } $key = trim( (string) $row['key'] ); if ( '' === $key || self::hidden( $key ) || isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $out[] = array( 'key' => $key, 'label' => isset( $row['label'] ) && is_scalar( $row['label'] ) ? trim( (string) $row['label'] ) : '', ); if ( count( $out ) >= self::MAX_FIELDS ) { break; } } return $out; } /** * The fields the shop picked. * * @param Settings $settings Plugin settings. * @return array */ public static function picked( Settings $settings ) { return self::clean( $settings->get( self::SETTING, array() ) ); } /** * The settings of earlier releases with the custom mode turned into this * list: the exterior and the interior field with their labels, in that * order, and WooCommerce's own details off — the custom mode showed the two * fields alone. Nothing changes while a list is stored, or while the custom * mode was not in use (another mode, or the details switched off). The old * keys stay; Settings::retire() drops them. * * @param array $values Settings values. * @return array */ public static function from_retired( array $values ) { if ( ! isset( $values['variation_mode'] ) || 'custom' !== $values['variation_mode'] || empty( $values['variation_display'] ) || ! empty( $values[ self::SETTING ] ) ) { return $values; } $rows = array(); $pairs = array( 'variation_ext_key' => 'variation_ext_label', 'variation_int_taxonomy' => 'variation_int_label', ); foreach ( $pairs as $key => $label ) { $rows[] = array( 'key' => isset( $values[ $key ] ) && is_scalar( $values[ $key ] ) ? (string) $values[ $key ] : '', 'label' => isset( $values[ $label ] ) && is_scalar( $values[ $label ] ) ? (string) $values[ $label ] : '', ); } $values[ self::SETTING ] = self::clean( $rows ); $values['variation_display'] = 0; return $values; } /** * An item's title and detail lines: WooCommerce's details as given, a * field the shop picked in place of WooCommerce's line for it, the other * picked fields after them, then `careback_item_details`. A picked field's * value is dropped from the end of the title ("Bag - Black" with "Black" * shown reads "Bag"), so it is not said twice. * * @param \WC_Order_Item $item Order line item. * @param string $title The item's name. * @param array $woo WooCommerce's details: [ field, line ] pairs. * @param Settings $settings Plugin settings. * @return array{title:string,details:string[]} */ public static function apply( $item, $title, array $woo, Settings $settings ) { $own = array(); foreach ( self::picked( $settings ) as $field ) { $value = self::value( $item, $field['key'] ); if ( '' === $value ) { continue; } $own[ rawurldecode( $field['key'] ) ] = '' !== $field['label'] ? $field['label'] . ': ' . $value : $value; $shorter = preg_replace( '/\s*[-–—]\s*' . preg_quote( $value, '/' ) . '\s*$/iu', '', $title ); $title = is_string( $shorter ) && '' !== $shorter ? $shorter : $title; } $lines = array(); foreach ( $woo as $pair ) { $key = rawurldecode( (string) $pair[0] ); if ( isset( $own[ $key ] ) ) { $lines[] = $own[ $key ]; unset( $own[ $key ] ); continue; } $lines[] = (string) $pair[1]; } $lines = array_merge( $lines, array_values( $own ) ); /** * Filters the detail lines shown under an item's name, one string each, * in the form, its check step, the emails and the request card. See * api/customer.md. * * @param string[] $lines The lines. * @param \WC_Order_Item $item The order item. * @param Settings $settings Plugin settings. */ $filtered = apply_filters( self::FILTER, $lines, $item, $settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_item_details, the final prefix. $details = array(); foreach ( is_array( $filtered ) ? $filtered : $lines as $line ) { $line = is_scalar( $line ) ? trim( wp_strip_all_tags( (string) $line ) ) : ''; if ( '' !== $line ) { $details[] = $line; } } return array( 'title' => $title, 'details' => $details, ); } /** * A field's value for an item: the item's own, a term's name for an * attribute's slug; else its product's; else the parent product's. '' when * none has it, or the field is hidden. * * @param \WC_Order_Item $item Order line item. * @param string $key Field. * @return string */ public static function value( $item, $key ) { $key = (string) $key; if ( '' === $key || self::hidden( $key ) ) { return ''; } $value = is_callable( array( $item, 'get_meta' ) ) ? self::text( $item->get_meta( $key, true ) ) : ''; if ( '' !== $value ) { $value = rawurldecode( $value ); if ( taxonomy_exists( $key ) ) { $term = get_term_by( 'slug', $value, $key ); $value = $term && ! is_wp_error( $term ) ? (string) $term->name : $value; } } elseif ( is_callable( array( $item, 'get_product' ) ) ) { $product = $item->get_product(); if ( $product ) { $value = self::product_value( (int) $product->get_id(), $key ); if ( '' === $value && $product->get_parent_id() ) { $value = self::product_value( (int) $product->get_parent_id(), $key ); } } } return trim( wp_strip_all_tags( $value ) ); } /** * A product's field: ACF's get_field() first (it formats the field), else * its post meta. * * @param int $product_id Product or variation. * @param string $key Field. * @return string */ private static function product_value( $product_id, $key ) { if ( $product_id <= 0 ) { return ''; } if ( function_exists( 'get_field' ) ) { $value = self::text( get_field( $key, $product_id ) ); if ( '' !== $value ) { return $value; } } return self::text( get_post_meta( $product_id, $key, true ) ); } /** * A stored value as text: a string or a number; anything else is none. * * @param mixed $value Value. * @return string */ private static function text( $value ) { return is_string( $value ) || is_int( $value ) || is_float( $value ) ? (string) $value : ''; } /** * The fields the items of the latest orders carry, field => its name in * WooCommerce (an attribute's label, else the field): what the screen * offers. Hidden fields never; fields with no text value neither. * * @return array */ public static function offered() { if ( ! function_exists( 'wc_get_orders' ) ) { return array(); } $keys = array(); $orders = wc_get_orders( array( 'limit' => self::RECENT_ORDERS, 'orderby' => 'date', 'order' => 'DESC', 'type' => 'shop_order', ) ); foreach ( is_array( $orders ) ? $orders : array() as $order ) { if ( ! $order instanceof \WC_Order ) { continue; } foreach ( $order->get_items() as $item ) { foreach ( $item->get_meta_data() as $meta ) { $key = (string) $meta->key; if ( '' === $key || self::hidden( $key ) || isset( $keys[ $key ] ) || '' === self::text( $meta->value ) ) { continue; } $keys[ $key ] = taxonomy_exists( $key ) ? (string) wc_attribute_label( $key ) : rawurldecode( $key ); } } } return $keys; } /** * Adds the row to the section of the item details, after its checkbox. * * @param array $fields Rows of the section. * @param string $section Section key. * @return array */ public static function fields( $fields, $section = '' ) { $fields = (array) $fields; if ( self::SECTION === $section ) { $fields[ self::SETTING ] = array( 'position' => 20, 'render' => array( __CLASS__, 'render' ), ); } return $fields; } /** * The row: a table of field and label, the shop's fields and empty rows * under them, the fields of recent orders offered in each field box. After * a refused save, the rows as they were sent, each message under its field. * * @param array $s Stored settings. * @param string $screen Screen id. * @param Settings $settings The screen's settings. * @return void */ public static function render( array $s, $screen = '', $settings = null ) { unset( $s, $screen ); $settings = $settings instanceof Settings ? $settings : new Settings(); $last = self::last(); $rows = $last ? $last['rows'] : self::picked( $settings ); $errors = $last ? $last['errors'] : array(); $blank = max( 0, min( self::BLANK_ROWS, self::MAX_FIELDS - count( $rows ) ) ); $offered = self::offered(); for ( $i = 0; $i < $blank; $i++ ) { $rows[] = array( 'key' => '', 'label' => '', ); } ?> $row ) : ?>
/> />
$label ) : ?>

$key, 'label' => $label, ); if ( '' === $key ) { $errors[ $i . ':key' ] = __( 'Choose a field for this label, or clear the label.', 'careback' ); } elseif ( self::hidden( $key ) ) { $errors[ $i . ':key' ] = __( 'Fields that start with _ are hidden and never shown. Choose another field.', 'careback' ); } elseif ( mb_strlen( $key ) > self::KEY_MAX ) { /* translators: %d: the most characters the field takes. */ $errors[ $i . ':key' ] = sprintf( _n( 'Use up to %d character.', 'Use up to %d characters.', self::KEY_MAX, 'careback' ), self::KEY_MAX ); } elseif ( isset( $seen[ $key ] ) ) { $errors[ $i . ':key' ] = __( 'This field is already in the list.', 'careback' ); } elseif ( $i >= self::MAX_FIELDS ) { /* translators: %d: the most fields the list takes. */ $errors[ $i . ':key' ] = sprintf( _n( 'Add up to %d field.', 'Add up to %d fields.', self::MAX_FIELDS, 'careback' ), self::MAX_FIELDS ); } if ( mb_strlen( $label ) > self::LABEL_MAX ) { /* translators: %d: the most characters the field takes. */ $errors[ $i . ':label' ] = sprintf( _n( 'Use up to %d character.', 'Use up to %d characters.', self::LABEL_MAX, 'careback' ), self::LABEL_MAX ); } $seen[ $key ] = true; } $last = array(); if ( $errors ) { $last = array( 'rows' => $rows, 'errors' => $errors, ); } self::keep( $last ); if ( ! $errors ) { $values[ self::SETTING ] = self::clean( $rows ); } return $values; } /** * Keeps the rows and messages of a refused save for the screen the save * returns to; clears them after a save that went through. * * @param array $last Rows and messages, or none. * @return void */ private static function keep( array $last ) { $user = get_current_user_id(); if ( ! $user ) { return; } if ( $last ) { set_transient( self::ERRORS . $user, $last, 5 * MINUTE_IN_SECONDS ); } else { delete_transient( self::ERRORS . $user ); } } /** * The rows and messages the last refused save left, or null. Read once and * then cleared, so they show on the screen right after the save only. * * @return array{rows:array,errors:array}|null */ private static function last() { $user = get_current_user_id(); $stored = $user ? get_transient( self::ERRORS . $user ) : false; if ( false === $stored ) { return null; } delete_transient( self::ERRORS . $user ); if ( ! is_array( $stored ) || ! isset( $stored['rows'], $stored['errors'] ) || ! is_array( $stored['rows'] ) || ! is_array( $stored['errors'] ) ) { return null; } $rows = array(); foreach ( $stored['rows'] as $row ) { $rows[] = array( 'key' => is_array( $row ) && isset( $row['key'] ) && is_scalar( $row['key'] ) ? (string) $row['key'] : '', 'label' => is_array( $row ) && isset( $row['label'] ) && is_scalar( $row['label'] ) ? (string) $row['label'] : '', ); } return array( 'rows' => $rows, 'errors' => $stored['errors'], ); } }