array( ... ), * 'fields' => array( array( 'id','type','key','label', ... ), ... ), * ) */ /** * One value inside an array setting: text cleaned, numbers and booleans kept. * * @param mixed $value A value from map_deep(). * @return mixed */ function mammoth_sanitize_deep_value( $value ) { return is_string( $value ) ? sanitize_text_field( $value ) : ( is_scalar( $value ) ? $value : '' ); } class Mammoth_Forms { /** Site-wide default number format for new forms (Settings > General). */ const OPT_NUMBER_FORMAT = 'mammoth_number_format'; /** * Ways to write numbers: key => decimal separator, thousands separator, * example. An empty thousands separator writes no grouping at all. * * @return array[] */ public static function number_formats() { return array( 'en' => array( '.', ',', '10,000.00' ), 'eu' => array( ',', '.', '10.000,00' ), 'space_comma' => array( ',', ' ', '10 000,00' ), 'space_point' => array( '.', ' ', '10 000.00' ), 'apostrophe' => array( '.', "'", "10'000.00" ), 'plain_point' => array( '.', '', '10000.00' ), 'plain_comma' => array( ',', '', '10000,00' ), ); } /** Choices for the Number format setting: key => label with an example. */ public static function number_format_choices( $with_custom = true ) { $labels = array( 'en' => __( '10,000.00 — comma for thousands, point for decimals', 'mammoth-forms' ), 'eu' => __( '10.000,00 — point for thousands, comma for decimals', 'mammoth-forms' ), 'space_comma' => __( '10 000,00 — space for thousands, comma for decimals', 'mammoth-forms' ), 'space_point' => __( '10 000.00 — space for thousands, point for decimals', 'mammoth-forms' ), 'apostrophe' => __( "10'000.00 — apostrophe for thousands, point for decimals", 'mammoth-forms' ), 'plain_point' => __( '10000.00 — no thousands separator, point for decimals', 'mammoth-forms' ), 'plain_comma' => __( '10000,00 — no thousands separator, comma for decimals', 'mammoth-forms' ), ); if ( $with_custom ) { $labels['custom'] = __( 'My own separators…', 'mammoth-forms' ); } return $labels; } /** The preset that writes these separators, or 'custom'. */ public static function number_format_for( $decimal, $thousands ) { foreach ( self::number_formats() as $key => $format ) { if ( (string) $format[0] === (string) $decimal && (string) $format[1] === (string) $thousands ) { return $key; } } return 'custom'; } /** * The default format of new forms: the one chosen in Settings, or the one * of the site's language (Greek WordPress writes 10.000,00). */ public static function default_number_format() { $saved = (string) get_option( self::OPT_NUMBER_FORMAT, '' ); if ( isset( self::number_formats()[ $saved ] ) ) { return $saved; } global $wp_locale; if ( isset( $wp_locale->number_format['decimal_point'], $wp_locale->number_format['thousands_sep'] ) ) { $thousands = html_entity_decode( (string) $wp_locale->number_format['thousands_sep'], ENT_QUOTES, 'UTF-8' ); // A no-break space (French and others) counts as a space here. $thousands = str_replace( array( "\u{a0}", "\u{202f}" ), ' ', $thousands ); $decimal = html_entity_decode( (string) $wp_locale->number_format['decimal_point'], ENT_QUOTES, 'UTF-8' ); $key = self::number_format_for( $decimal, $thousands ); if ( 'custom' !== $key ) { return $key; } // Some languages translate only the decimal comma (Greek WordPress keeps "," for // thousands too): a decimal comma then goes with a point for thousands. if ( ',' === $decimal ) { return 'eu'; } } return 'en'; } /** * A form configuration sent as JSON (the builder), decoded and cleaned in * one step: every field and setting goes through normalize_config(). * * @param string $json JSON text. * @return array|null The clean configuration, or null when it is not a form. */ public static function sanitize_config_json( $json ) { $data = json_decode( (string) $json, true ); return is_array( $data ) ? self::normalize_config( $data ) : null; } public static function default_settings() { $format = self::number_formats()[ self::default_number_format() ]; return array( 'description' => '', 'submit_text' => __( 'Submit', 'mammoth-forms' ), 'success_message' => __( 'Thanks! Your submission was received.', 'mammoth-forms' ), 'redirect_url' => '', 'confirmation_type' => 'message', 'confirmation_page' => 0, 'after_submit' => 'reset', 'save_entries' => 'yes', 'notify_admin' => 'yes', 'notify_to' => '', 'notify_subject' => '', 'notify_bcc' => '', 'notify_from_name' => '', 'notify_from_email' => '', 'notify_reply_to' => '', 'notify_format' => 'html', 'notify_body' => "{all_fields}\n\n{entry_link}", 'confirm_visitor' => 'no', 'confirm_to' => '{email}', 'confirm_subject' => '', 'confirm_body' => '', 'number_format' => self::default_number_format(), 'decimal_sep' => $format[0], 'thousand_sep' => $format[1], 'currency' => '', 'currency_pos' => 'before', 'labels_position' => 'top', 'honeypot' => 'yes', 'style_theme' => 'default', 'style_accent' => '', 'style_radius' => '', 'style_size' => 'normal', 'style_width' => 'full', 'url_prefill' => 'yes', 'email_sending' => '', 'login_required' => 'no', 'save_resume' => 'no', 'save_resume_text' => '', 'save_resume_days' => '30', 'save_resume_email' => 'yes', ); } /** * A brand new form: one number field and one calculated field, so the * builder never opens on an empty canvas. */ public static function starter_config() { return array( 'settings' => self::default_settings(), 'fields' => array( array( 'id' => 'f1', 'type' => 'number', 'key' => 'quantity', 'label' => __( 'Quantity', 'mammoth-forms' ), 'width' => 50, 'required' => 'yes', 'default' => '1', 'min' => '1', 'step' => '1', ), array( 'id' => 'f2', 'type' => 'number', 'key' => 'unit_price', 'label' => __( 'Unit price', 'mammoth-forms' ), 'width' => 50, 'default' => '10', 'step' => 'any', ), array( 'id' => 'f3', 'type' => 'calculated', 'key' => 'total', 'label' => __( 'Total (VAT included)', 'mammoth-forms' ), 'width' => 100, 'formula' => '{quantity} * {unit_price} + 24%', 'decimals' => 2, 'suffix' => ' €', ), ), ); } public static function get( $id ) { global $wpdb; $table = Mammoth_Install::forms_table(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, (int) $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no WP API for it. if ( ! $row ) { return null; } $row['config'] = self::normalize_config( json_decode( $row['config'], true ) ); return $row; } public static function all() { global $wpdb; $table = Mammoth_Install::forms_table(); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC', $table ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no WP API for it. foreach ( $rows as &$row ) { $row['config'] = self::normalize_config( json_decode( $row['config'], true ) ); } return $rows; } public static function create( $title, $config = null ) { global $wpdb; $now = current_time( 'mysql' ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table, no WP API for it. Mammoth_Install::forms_table(), array( 'title' => $title, 'config' => wp_json_encode( self::normalize_config( $config ? $config : self::starter_config() ) ), 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, ), array( '%s', '%s', '%s', '%s', '%s' ) ); return (int) $wpdb->insert_id; } public static function update( $id, $title, $config ) { global $wpdb; return false !== $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no WP API for it. Mammoth_Install::forms_table(), array( 'title' => $title, 'config' => wp_json_encode( self::normalize_config( $config ) ), 'updated_at' => current_time( 'mysql' ), ), array( 'id' => (int) $id ), array( '%s', '%s', '%s' ), array( '%d' ) ); } public static function duplicate( $id ) { $form = self::get( $id ); if ( ! $form ) { return 0; } /* translators: %s: original form title */ return self::create( sprintf( __( '%s (copy)', 'mammoth-forms' ), $form['title'] ), $form['config'] ); } public static function delete( $id ) { global $wpdb; Mammoth_Health::forget( $id ); /** Before a form and all of its entries are deleted. */ do_action( 'mammoth_form_deleting', (int) $id ); $wpdb->delete( Mammoth_Install::entries_table(), array( 'form_id' => (int) $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no WP API for it. return (bool) $wpdb->delete( Mammoth_Install::forms_table(), array( 'id' => (int) $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, no WP API for it. } /** * Fills in missing keys so downstream code can read without isset() noise. */ public static function normalize_config( $config ) { if ( ! is_array( $config ) ) { $config = array(); } $settings = isset( $config['settings'] ) && is_array( $config['settings'] ) ? $config['settings'] : array(); // A form saved before the Number format setting existed keeps the separators it has. if ( ! isset( $settings['number_format'] ) && ( isset( $settings['decimal_sep'] ) || isset( $settings['thousand_sep'] ) ) ) { $defaults = self::default_settings(); $settings['number_format'] = self::number_format_for( isset( $settings['decimal_sep'] ) ? $settings['decimal_sep'] : $defaults['decimal_sep'], isset( $settings['thousand_sep'] ) ? $settings['thousand_sep'] : $defaults['thousand_sep'] ); } // A form saved with only a redirect address (no confirmation type) keeps redirecting. if ( ! isset( $settings['confirmation_type'] ) && ! empty( $settings['redirect_url'] ) ) { $settings['confirmation_type'] = 'url'; } $fields = isset( $config['fields'] ) && is_array( $config['fields'] ) ? $config['fields'] : array(); $out = array( 'settings' => self::sanitize_settings( array_merge( self::default_settings(), $settings ) ), 'fields' => array(), ); $used_keys = array(); $index = 0; foreach ( $fields as $field ) { if ( ! is_array( $field ) || empty( $field['type'] ) ) { continue; } $index++; $field = Mammoth_Fields::normalize_field( $field, $index ); // Field keys are formula identifiers, so they must be unique. $base = $field['key']; $try = $base; $n = 2; while ( isset( $used_keys[ $try ] ) ) { $try = $base . '_' . $n; $n++; } $field['key'] = $try; $used_keys[ $try ] = true; $out['fields'][] = $field; } return apply_filters( 'mammoth_normalize_config', $out, $config ); } /** * Cleans form-level settings, driven by the same schema the builder renders. * * Only users who can manage options ever write these, so this is hygiene * rather than a trust boundary. Add-on settings that are not scalars are * left to the `mammoth_sanitize_settings` filter. */ public static function sanitize_settings( $settings ) { $defs = array(); foreach ( Mammoth_Admin::settings_schema() as $group ) { foreach ( $group['fields'] as $name => $def ) { $defs[ $name ] = $def; } } $out = array(); foreach ( $settings as $key => $value ) { if ( ! isset( $defs[ $key ] ) ) { // Add-ons clean their own from the raw settings (second argument of the filter below). $out[ $key ] = is_scalar( $value ) ? sanitize_text_field( (string) $value ) : map_deep( $value, 'mammoth_sanitize_deep_value' ); continue; } $def = $defs[ $key ]; switch ( $def['type'] ) { case 'toggle': $out[ $key ] = Mammoth_Fields::yes_no( $value ); break; case 'textarea': $out[ $key ] = wp_kses_post( (string) $value ); break; case 'page': $out[ $key ] = absint( $value ); break; case 'color': $out[ $key ] = (string) sanitize_hex_color( (string) $value ); break; case 'select': $choices = isset( $def['choices'] ) ? array_keys( $def['choices'] ) : array(); if ( in_array( (string) $value, array_map( 'strval', $choices ), true ) ) { $out[ $key ] = (string) $value; } elseif ( ! empty( $def['keep_unknown'] ) && '' !== sanitize_key( (string) $value ) ) { $out[ $key ] = sanitize_key( (string) $value ); } else { $out[ $key ] = (string) reset( $choices ); } break; default: if ( 'redirect_url' === $key && false !== strpos( (string) $value, '{' ) ) { // Merge tags survive; the finished address is cleaned when it is used. $out[ $key ] = sanitize_text_field( (string) $value ); } elseif ( false !== strpos( $key, 'url' ) ) { $out[ $key ] = esc_url_raw( (string) $value ); } elseif ( '_sep' === substr( $key, -4 ) ) { // A separator can legitimately be a space. $out[ $key ] = Mammoth_Fields::sanitize_affix( $value ); } else { $out[ $key ] = sanitize_text_field( (string) $value ); } } } // A preset writes its own separators; "custom" keeps the ones typed. $formats = self::number_formats(); if ( isset( $out['number_format'], $formats[ $out['number_format'] ] ) ) { $out['decimal_sep'] = $formats[ $out['number_format'] ][0]; $out['thousand_sep'] = $formats[ $out['number_format'] ][1]; } if ( isset( $out['decimal_sep'] ) && '' === $out['decimal_sep'] ) { $out['decimal_sep'] = '.'; } return apply_filters( 'mammoth_sanitize_settings', $out, $settings ); } /** * Fields indexed by key. */ public static function fields_by_key( $config ) { $map = array(); foreach ( $config['fields'] as $field ) { $map[ $field['key'] ] = $field; } return $map; } }