prefix . 'bylt_form_entries'; } public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $t = self::table(); dbDelta( "CREATE TABLE {$t} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, form_id bigint(20) unsigned NOT NULL DEFAULT 0, status varchar(10) NOT NULL DEFAULT 'new', email varchar(190) NOT NULL DEFAULT '', data longtext NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, product_id bigint(20) unsigned NOT NULL DEFAULT 0, page_url varchar(500) NOT NULL DEFAULT '', ip varchar(64) NOT NULL DEFAULT '', created_gmt datetime NULL DEFAULT NULL, PRIMARY KEY (id), KEY form_status (form_id,status), KEY email (email), KEY created (created_gmt) ) {$wpdb->get_charset_collate()};" ); update_option( 'bylt_forms_db_version', self::DB_VERSION, false ); } public static function maybe_install() { if ( get_option( 'bylt_forms_db_version' ) !== self::DB_VERSION ) { self::install(); } } public static function register_cpt() { register_post_type( self::CPT, array( 'label' => __( 'Forms', 'byltcommerce-toolkit' ), 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'rewrite' => false, 'query_var' => false, 'supports' => array( 'title' ), 'capability_type' => 'page', ) ); } /* --------------------------------------------------------------------- * Forms * ------------------------------------------------------------------- */ public static function default_settings() { return array( 'kind' => 'contact', // contact | quote 'submit_label' => 'Send', 'success' => 'Thanks! We\'ll be in touch soon.', 'redirect' => '', 'notify' => 'yes', 'notify_to' => '', // Blank = site admin email. 'notify_subject' => 'New {form} entry', 'confirm' => 'no', 'confirm_subject' => 'We received your message', 'confirm_message' => "Hi {name},\n\nThanks for getting in touch. Here's a copy of what you sent:\n\n{all_fields}", 'store' => 'yes', 'protect' => 'yes', // Turnstile/reCAPTCHA via Form Protection, when set up. 'product_button' => 'no', // Quote forms: no | all | cats 'button_cats' => array(), 'button_label' => 'Request a quote', 'page_id' => 0, // Page the form lives on (for the product button). 'css_class' => '', // Extra classes on the
. 'spam_js' => 'yes', // Only accept posts from a browser that ran the page's script. 'spam_rate' => 5, // Entries per visitor (IP) per form in 10 minutes; 0 = no limit. 'spam_links' => 3, // More links than this marks the entry as spam; 0 = no limit. ); } public static function blank_field() { return array( 'key' => '', 'type' => 'text', 'label' => '', 'required' => false, 'placeholder' => '', 'options' => array(), 'default' => '', 'param' => '', // Fill from this URL parameter (e.g. ?product=…). 'width' => 'full', // full | half 'help' => '', 'enabled' => true, // Off = kept in the editor but not on the form. 'css' => '', // Extra classes on the field wrapper. 'show' => 'all', // all | guests | users 'map' => '', // Save to this customer field (see Bylt_Forms::map_targets()). 'locked' => false, // Set by the form type (e.g. account fields); not editable. 'cond' => array(), // Conditional logic, see clean_cond(); empty = always shown. ); } /** Clean a space-separated list of CSS class names. */ public static function css_classes( $value ) { $out = array(); foreach ( preg_split( '/\s+/', (string) $value ) as $c ) { $c = sanitize_html_class( $c ); if ( '' !== $c ) { $out[ $c ] = true; } } return implode( ' ', array_slice( array_keys( $out ), 0, 10 ) ); } /** Whether a field shows (and is validated) for the current visitor. */ public static function field_live( array $f, $logged_in = null ) { $logged_in = null === $logged_in ? is_user_logged_in() : $logged_in; if ( empty( $f['enabled'] ) ) { return false; } if ( 'guests' === $f['show'] && $logged_in ) { return false; } return ! ( 'users' === $f['show'] && ! $logged_in ); } /** * Clean a list of fields: valid types, unique keys, and the fields the form * type locks (e.g. account fields on a wholesale application) put back in. */ public static function clean_fields( array $fields, $kind = 'contact' ) { $out = array(); $seen = array(); $locked = array(); foreach ( Bylt_Forms::locked_fields( $kind ) as $lf ) { $locked[ $lf['key'] ] = $lf; } $fields = self::merge_locked( $fields, $locked ); $maps = Bylt_Forms::map_targets(); foreach ( $fields as $f ) { if ( ! is_array( $f ) ) { continue; } $f = array_merge( self::blank_field(), array_intersect_key( $f, self::blank_field() ) ); $f['type'] = in_array( $f['type'], self::TYPES, true ) ? $f['type'] : 'text'; $f['label'] = sanitize_text_field( $f['label'] ); if ( 'html' === $f['type'] ) { $f['help'] = wp_kses_post( $f['help'] ); } else { $f['help'] = sanitize_text_field( $f['help'] ); } if ( '' === $f['label'] && 'html' !== $f['type'] ) { continue; } $key = sanitize_key( str_replace( '-', '_', $f['key'] ? $f['key'] : sanitize_title( $f['label'] ? $f['label'] : $f['type'] ) ) ); $key = $key ? $key : 'field'; $base = $key; for ( $n = 2; isset( $seen[ $key ] ); $n++ ) { $key = $base . '_' . $n; } $seen[ $key ] = true; $f['key'] = $key; $f['required'] = ! empty( $f['required'] ) && 'html' !== $f['type'] && 'hidden' !== $f['type']; $f['placeholder'] = sanitize_text_field( $f['placeholder'] ); $f['default'] = sanitize_text_field( $f['default'] ); $f['param'] = sanitize_key( $f['param'] ); $f['width'] = 'half' === $f['width'] ? 'half' : 'full'; $opts = is_array( $f['options'] ) ? $f['options'] : preg_split( '/\r\n|\r|\n/', (string) $f['options'] ); $f['options'] = array_values( array_filter( array_map( 'sanitize_text_field', array_map( 'trim', $opts ) ), 'bylt_filled' ) ); $f['css'] = self::css_classes( $f['css'] ); $f['show'] = in_array( $f['show'], self::SHOW, true ) ? $f['show'] : 'all'; $f['map'] = isset( $maps[ $f['map'] ] ) && ! in_array( $f['type'], array( 'html', 'password' ), true ) ? $f['map'] : ''; if ( 'file' === $f['type'] ) { $f['default'] = ''; $f['param'] = ''; $f['map'] = isset( $maps[ $f['map'] ] ) && 0 === strpos( $f['map'], 'file:' ) ? $f['map'] : ''; } elseif ( 0 === strpos( $f['map'], 'file:' ) ) { $f['map'] = ''; } $f['locked'] = ! empty( $f['locked'] ) && isset( $locked[ $key ] ); $f['enabled'] = $f['locked'] || ! in_array( $f['enabled'], array( false, 0, '0', 'no', '' ), true ); if ( 'password' === $f['type'] ) { $f['default'] = ''; $f['param'] = ''; } $out[] = $f; } // Conditions last, once every key is known. $keys = wp_list_pluck( $out, 'key' ); foreach ( $out as $i => $f ) { $out[ $i ]['cond'] = $f['locked'] ? array() : self::clean_cond( $f['cond'], $f['key'], $keys ); } return $out; } /** * Conditional logic for one field: * array( 'action' => show|hide, 'match' => all|any, 'rules' => array( array( 'field' => key, 'op' => one of OPS, 'value' => text ) ) ). * Rules about the field itself or fields that no longer exist are dropped; no rules = always shown. * * @param mixed $c */ public static function clean_cond( $c, $self, array $keys ) { if ( ! is_array( $c ) || empty( $c['action'] ) || ! in_array( $c['action'], array( 'show', 'hide' ), true ) ) { return array(); } $rules = array(); foreach ( isset( $c['rules'] ) && is_array( $c['rules'] ) ? $c['rules'] : array() as $r ) { if ( ! is_array( $r ) || empty( $r['field'] ) ) { continue; } $field = sanitize_key( (string) $r['field'] ); $op = isset( $r['op'] ) && in_array( $r['op'], self::OPS, true ) ? $r['op'] : 'is'; if ( $field === $self || ! in_array( $field, $keys, true ) ) { continue; } $rules[] = array( 'field' => $field, 'op' => $op, 'value' => in_array( $op, array( 'empty', 'not_empty' ), true ) ? '' : sanitize_text_field( isset( $r['value'] ) ? (string) $r['value'] : '' ), ); if ( count( $rules ) >= self::MAX_RULES ) { break; } } if ( ! $rules ) { return array(); } return array( 'action' => $c['action'], 'match' => isset( $c['match'] ) && 'any' === $c['match'] ? 'any' : 'all', 'rules' => $rules, ); } /** * Which fields the visitor sees, given the values on the form: the "Show to" and * On/Off settings, then each field's conditions. A field hidden by its conditions * counts as empty for fields that depend on it. Same logic as forms.js. * * @param array $fields The form's fields. * @param array $values key => value (array for "Choose several"). * @param bool|null $logged_in Visitor or logged-in customer (null = current user). * @return array key => shown, for every field. */ public static function visible( array $fields, array $values, $logged_in = null ) { $by = array(); foreach ( $fields as $f ) { $f = self::with_defaults( $f ); if ( '' !== $f['key'] ) { $by[ $f['key'] ] = $f; } } $memo = array(); $out = array(); foreach ( array_keys( $by ) as $k ) { $out[ $k ] = self::shown( $k, $by, $values, $logged_in, $memo, array() ); } return $out; } /** @param array $memo Keys are field keys (PHP turns numeric ones into ints). */ private static function shown( $key, array $by, array $values, $logged_in, array &$memo, array $stack ) { if ( isset( $memo[ $key ] ) ) { return $memo[ $key ]; } if ( ! isset( $by[ $key ] ) || ! self::field_live( $by[ $key ], $logged_in ) ) { $memo[ $key ] = false; return false; } $c = $by[ $key ]['cond']; if ( empty( $c['rules'] ) ) { $memo[ $key ] = true; return true; } if ( isset( $stack[ $key ] ) ) { return true; // Fields that depend on each other: stop going round, use the values as they are. } $stack[ $key ] = true; $hits = 0; foreach ( $c['rules'] as $r ) { $val = isset( $values[ $r['field'] ] ) && self::shown( $r['field'], $by, $values, $logged_in, $memo, $stack ) ? $values[ $r['field'] ] : ''; $hits += self::rule_matches( $r, $val ) ? 1 : 0; } $matched = 'any' === $c['match'] ? $hits > 0 : count( $c['rules'] ) === $hits; $memo[ $key ] = 'show' === $c['action'] ? $matched : ! $matched; return $memo[ $key ]; } /** * One rule against a value. Text is compared without regard to case or * surrounding spaces; "Choose several" matches when any ticked choice does. * * @param array $r field, op, value. * @param string|array $val The other field's value. */ public static function rule_matches( array $r, $val ) { $list = array(); foreach ( (array) $val as $v ) { $v = is_scalar( $v ) ? strtolower( trim( (string) $v ) ) : ''; if ( '' !== $v ) { $list[] = $v; } } $want = strtolower( trim( (string) $r['value'] ) ); switch ( $r['op'] ) { case 'empty': return ! $list; case 'not_empty': return (bool) $list; case 'is': return in_array( $want, $list, true ) || ( '' === $want && ! $list ); case 'is_not': return ! ( in_array( $want, $list, true ) || ( '' === $want && ! $list ) ); case 'contains': case 'not_contains': $found = false; foreach ( $list as $v ) { $found = $found || ( '' !== $want && false !== strpos( $v, $want ) ); } return 'contains' === $r['op'] ? $found : ! $found; case 'gt': case 'lt': if ( 1 !== count( $list ) || ! is_numeric( str_replace( ',', '', $list[0] ) ) || ! is_numeric( $want ) ) { return false; } $n = (float) str_replace( ',', '', $list[0] ); return 'gt' === $r['op'] ? $n > (float) $want : $n < (float) $want; } return false; } /** * Put the form type's locked fields in: the saved label, placeholder, help, * width and classes are kept; type, key, required and who sees it are not * up to the editor. Missing ones are added at the top, in order. */ private static function merge_locked( array $fields, array $locked ) { $found = array(); foreach ( $fields as $i => $f ) { if ( ! is_array( $f ) ) { continue; } $key = isset( $f['key'] ) ? (string) $f['key'] : ''; if ( isset( $locked[ $key ] ) && ! isset( $found[ $key ] ) ) { $keep = array_intersect_key( $f, array_flip( array( 'label', 'placeholder', 'help', 'width', 'css' ) ) ); $keep = array_filter( $keep, 'bylt_filled' ); $fields[ $i ] = array_merge( self::blank_field(), $locked[ $key ], $keep, array( 'locked' => true, 'enabled' => true ) ); $found[ $key ] = true; } else { $fields[ $i ]['locked'] = false; } } $missing = array(); foreach ( $locked as $key => $lf ) { if ( ! isset( $found[ $key ] ) ) { $missing[] = array_merge( self::blank_field(), $lf, array( 'locked' => true, 'enabled' => true ) ); } } return array_merge( $missing, array_values( $fields ) ); } public static function clean_settings( array $s ) { $d = self::default_settings(); $s = array_merge( $d, array_intersect_key( $s, $d ) ); foreach ( array( 'submit_label', 'success', 'notify_subject', 'confirm_subject', 'button_label' ) as $k ) { $s[ $k ] = sanitize_text_field( $s[ $k ] ); } $s['confirm_message'] = sanitize_textarea_field( $s['confirm_message'] ); $s['redirect'] = esc_url_raw( $s['redirect'] ); $s['notify_to'] = implode( ', ', array_filter( array_map( 'sanitize_email', array_map( 'trim', explode( ',', (string) $s['notify_to'] ) ) ) ) ); foreach ( array( 'notify', 'confirm', 'store', 'protect' ) as $k ) { $s[ $k ] = in_array( $s[ $k ], array( 'yes', true, 1, '1' ), true ) ? 'yes' : 'no'; } $s['kind'] = isset( Bylt_Forms::kinds()[ $s['kind'] ] ) ? $s['kind'] : 'contact'; $s['css_class'] = self::css_classes( $s['css_class'] ); $s['spam_js'] = in_array( $s['spam_js'], array( 'yes', true, 1, '1' ), true ) ? 'yes' : 'no'; $s['spam_rate'] = min( 1000, max( 0, (int) $s['spam_rate'] ) ); $s['spam_links'] = min( 1000, max( 0, (int) $s['spam_links'] ) ); $s['product_button'] = in_array( $s['product_button'], array( 'all', 'cats' ), true ) ? $s['product_button'] : 'no'; $s['button_cats'] = array_values( array_filter( array_map( 'absint', (array) $s['button_cats'] ) ) ); $s['page_id'] = absint( $s['page_id'] ); $s['submit_label'] = '' === $s['submit_label'] ? $d['submit_label'] : $s['submit_label']; return $s; } /** @return array|null array( id, name, fields, settings ) */ public static function get( $id ) { $post = get_post( $id ); if ( ! $post || self::CPT !== $post->post_type || 'trash' === $post->post_status ) { return null; } $data = get_post_meta( $post->ID, self::META, true ); $data = is_array( $data ) ? $data : array(); return array( 'id' => (int) $post->ID, 'name' => $post->post_title, 'fields' => isset( $data['fields'] ) && is_array( $data['fields'] ) ? array_map( array( __CLASS__, 'with_defaults' ), array_filter( $data['fields'], 'is_array' ) ) : array(), 'settings' => array_merge( self::default_settings(), isset( $data['settings'] ) && is_array( $data['settings'] ) ? $data['settings'] : array() ), ); } /** A saved field with any options added since it was saved. */ public static function with_defaults( array $f ) { return array_merge( self::blank_field(), $f ); } public static function all() { $ids = get_posts( array( 'post_type' => self::CPT, 'post_status' => 'publish', 'numberposts' => 500, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids' ) ); return array_values( array_filter( array_map( array( __CLASS__, 'get' ), $ids ) ) ); } /** Create or update. Returns the form id. */ public static function save( array $form ) { $name = sanitize_text_field( isset( $form['name'] ) ? $form['name'] : '' ); $name = '' === $name ? __( 'Untitled form', 'byltcommerce-toolkit' ) : $name; $settings = self::clean_settings( isset( $form['settings'] ) ? (array) $form['settings'] : array() ); $data = array( 'fields' => self::clean_fields( isset( $form['fields'] ) ? (array) $form['fields'] : array(), $settings['kind'] ), 'settings' => $settings, ); $id = ! empty( $form['id'] ) && self::get( $form['id'] ) ? (int) $form['id'] : 0; if ( $id ) { wp_update_post( array( 'ID' => $id, 'post_title' => $name ) ); } else { $id = wp_insert_post( array( 'post_type' => self::CPT, 'post_status' => 'publish', 'post_title' => $name ) ); if ( is_wp_error( $id ) || ! $id ) { return 0; } } update_post_meta( $id, self::META, $data ); return (int) $id; } public static function delete( $id, $with_entries = true ) { global $wpdb; if ( ! self::get( $id ) ) { return; } wp_delete_post( $id, true ); if ( $with_entries ) { do { $batch = self::entries( array( 'form_id' => (int) $id, 'per_page' => 200 ) ); } while ( $batch['items'] && self::delete_entries( wp_list_pluck( $batch['items'], 'id' ) ) ); } } /* --------------------------------------------------------------------- * Entries * ------------------------------------------------------------------- */ private static function hydrate( $row ) { if ( ! $row ) { return null; } $row = (array) $row; foreach ( array( 'id', 'form_id', 'user_id', 'product_id' ) as $k ) { $row[ $k ] = (int) $row[ $k ]; } $data = json_decode( (string) $row['data'], true ); $row['data'] = is_array( $data ) ? $data : array(); return $row; } public static function add_entry( array $e ) { global $wpdb; $row = array( 'form_id' => (int) $e['form_id'], 'status' => isset( $e['status'] ) && in_array( $e['status'], self::STATUSES, true ) ? $e['status'] : 'new', 'email' => isset( $e['email'] ) ? substr( sanitize_email( $e['email'] ), 0, 190 ) : '', 'data' => wp_json_encode( isset( $e['data'] ) ? (array) $e['data'] : array() ), 'user_id' => isset( $e['user_id'] ) ? (int) $e['user_id'] : 0, 'product_id' => isset( $e['product_id'] ) ? (int) $e['product_id'] : 0, 'page_url' => isset( $e['page_url'] ) ? substr( esc_url_raw( $e['page_url'] ), 0, 500 ) : '', 'ip' => isset( $e['ip'] ) ? substr( (string) $e['ip'], 0, 64 ) : '', 'created_gmt' => ! empty( $e['created_gmt'] ) ? $e['created_gmt'] : gmdate( 'Y-m-d H:i:s' ), ); $wpdb->insert( self::table(), $row ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->insert_id; } public static function entry( $id ) { global $wpdb; $t = self::table(); return self::hydrate( $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$t} WHERE id = %d", $id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery } public static function set_status( $ids, $status ) { global $wpdb; $ids = array_filter( array_map( 'absint', (array) $ids ) ); if ( ! $ids || ! in_array( $status, self::STATUSES, true ) ) { return 0; } $t = self::table(); $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); return (int) $wpdb->query( $wpdb->prepare( "UPDATE {$t} SET status = %s WHERE id IN ({$in})", array_merge( array( $status ), array_values( $ids ) ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table name and %d placeholders only. } /** Uploaded files of an entry (field key => stored path). */ public static function entry_files( array $e ) { return isset( $e['data']['_files'] ) && is_array( $e['data']['_files'] ) ? $e['data']['_files'] : array(); } public static function delete_entries( $ids ) { global $wpdb; $ids = array_filter( array_map( 'absint', (array) $ids ) ); if ( ! $ids ) { return 0; } foreach ( self::entries( array( 'ids' => $ids, 'per_page' => count( $ids ) ) )['items'] as $e ) { foreach ( self::entry_files( $e ) as $rel ) { Bylt_Forms_Files::delete( $rel ); } } $t = self::table(); $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$t} WHERE id IN ({$in})", array_values( $ids ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table name and %d placeholders only. } /** * @param array $args form_id, status, not_status, search, email, before (Y-m-d H:i:s), ids, page, per_page * @return array{items: array[], total: int} */ public static function entries( array $args = array() ) { global $wpdb; $args = wp_parse_args( $args, array( 'form_id' => 0, 'status' => '', 'search' => '', 'not_status' => '', 'email' => '', 'before' => '', 'ids' => array(), 'page' => 1, 'per_page' => 50 ) ); $where = array( '1=1' ); $params = array(); if ( $args['form_id'] ) { $where[] = 'form_id = %d'; $params[] = $args['form_id']; } if ( $args['status'] ) { $where[] = 'status = %s'; $params[] = $args['status']; } if ( $args['not_status'] ) { $where[] = 'status <> %s'; $params[] = $args['not_status']; } if ( $args['email'] ) { $where[] = 'email = %s'; $params[] = $args['email']; } if ( $args['before'] ) { $where[] = 'created_gmt < %s'; $params[] = $args['before']; } if ( $args['search'] ) { $where[] = '(email LIKE %s OR data LIKE %s)'; $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $params[] = $like; $params[] = $like; } if ( $args['ids'] ) { $where[] = 'id IN (' . implode( ',', array_map( 'absint', (array) $args['ids'] ) ) . ')'; } $t = self::table(); $w = implode( ' AND ', $where ); $limit = max( 1, (int) $args['per_page'] ); $offset = ( max( 1, (int) $args['page'] ) - 1 ) * $limit; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders $total = (int) ( $params ? $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$t} WHERE {$w}", $params ) ) : $wpdb->get_var( "SELECT COUNT(*) FROM {$t} WHERE {$w}" ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$t} WHERE {$w} ORDER BY created_gmt DESC, id DESC LIMIT %d OFFSET %d", array_merge( $params, array( $limit, $offset ) ) ) ); // phpcs:enable return array( 'items' => array_map( array( __CLASS__, 'hydrate' ), $rows ? $rows : array() ), 'total' => $total, ); } /** form_id => array( status => count ) */ public static function counts() { global $wpdb; $t = self::table(); $out = array(); foreach ( (array) $wpdb->get_results( "SELECT form_id, status, COUNT(*) AS n FROM {$t} GROUP BY form_id, status" ) as $r ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $out[ (int) $r->form_id ][ $r->status ] = (int) $r->n; } return $out; } }