'text', 'textarea' => 'textarea', 'rte' => 'textarea', 'email' => 'email', 'url' => 'url', 'number' => 'number', 'range' => 'number', 'scale' => 'number', 'star' => 'number', 'nps' => 'number', 'phone' => 'tel', 'hidden' => 'hidden', 'select' => 'select', 'lookup' => 'select', 'radio' => 'radio', 'checkbox' => 'checkbox', 'toggle' => 'checkbox', 'gdpr' => 'checkbox', 'date' => 'date', 'time' => 'text', 'tag' => 'text', 'name' => 'text', 'address' => 'text', 'ranking' => 'other', 'likert' => 'other', ); /** Field types whose value is an array of named parts, each relayed on its own. */ const COMPOSITE = array( 'name', 'address' ); /** @var array Each entry's status just before it is updated in this request, by entry ID. */ private $before_update = array(); /** * {@inheritDoc} */ public function slug() { return 'formidable'; } /** * {@inheritDoc} */ public function name() { return 'Formidable Forms'; } /** * {@inheritDoc} */ public function is_available() { return class_exists( 'FrmForm' ) && class_exists( 'FrmEntry' ) && class_exists( 'FrmField' ); } /** * {@inheritDoc} */ public function forms() { if ( ! class_exists( 'FrmForm' ) ) { return array(); } // Published, not templates, not repeater or embedded child forms; one query. $rows = \FrmForm::get_published_forms( array(), self::MAX_FORMS, 'exclude' ); $forms = array(); foreach ( (array) $rows as $row ) { if ( ! is_object( $row ) ) { continue; } $forms[] = array( 'id' => (string) $row->id, 'title' => self::plain( $row->name ), ); } return $forms; } /** * {@inheritDoc} */ public function fields( $form_id ) { $fields = array(); foreach ( $this->answers( $form_id, true ) as $answer ) { $fields[] = array( 'name' => $answer['name'], 'label' => $answer['label'], 'type' => $answer['type'], ); } return $fields; } /** * {@inheritDoc} */ public function register() { if ( ! $this->claim( 'hooks' ) ) { return; } /* * `frm_after_create_entry` ($entry_id, $form_id, array( 'is_child' => bool )), fired by * FrmEntry::after_entry_created_actions() once the entry row and its values are saved, which only * happens after validation and the spam checks (honeypot, captcha, Akismet, the deny list). AJAX * and plain POST submissions both create the entry through FrmEntry::create(). It also fires for a * saved draft, for a spam entry kept for review (is_draft 4), for repeater child entries and for * imports, so relay() skips those, as Formidable's own form actions do. Priority 40 runs after * Formidable's own actions (20), the notification emails among them. */ add_action( 'frm_after_create_entry', array( $this, 'handle' ), 40, 3 ); /* * A draft or in-progress entry is completed by FrmEntry::update(), which fires no create hook: * `frm_update_entry` (a filter on the new row, given the entry ID, applied before the row is * written) records the status the entry had, and `frm_after_update_entry` ($entry_id, $form_id) * relays it if it was not complete and now is. An edit of an entry that was already complete, * in wp-admin or anywhere else, never relays it. */ add_filter( 'frm_update_entry', array( $this, 'remember_status' ), 10, 2 ); add_action( 'frm_after_update_entry', array( $this, 'handle_update' ), 40, 2 ); /* * `frm_entry_marked_not_spam` ($entry_id, $action_ids), Formidable 7.0+, fired by * FrmSpamEntriesController::not_spam() after restore_from_spam() set the entry back to submitted * with a direct query (no update hook fires). An entry that was relayed before it was marked as * spam carries the marker and is not relayed again. */ add_action( 'frm_entry_marked_not_spam', array( $this, 'handle_not_spam' ), 10, 1 ); } /** * Relay a created entry. * * @param int $entry_id The entry's ID. * @param int $form_id The form's ID. * @param array $args `is_child`: a repeater or embedded form's child entry. * @return void */ public function handle( $entry_id, $form_id = 0, $args = array() ) { if ( empty( $args['is_child'] ) ) { $this->relay( $entry_id ); } } /** * Remember an entry's status before an update writes the new one. A filter: returns the row as given. * * @param array $new_values The row about to be written. * @param int|string $entry_id The entry's ID. * @return array */ public function remember_status( $new_values, $entry_id = 0 ) { try { if ( (int) $entry_id && class_exists( 'FrmEntry' ) ) { $entry = \FrmEntry::getOne( (int) $entry_id ); if ( is_object( $entry ) && isset( $entry->is_draft ) ) { $this->before_update[ (int) $entry_id ] = (int) $entry->is_draft; } } } catch ( \Throwable $e ) { $this->fail( $e ); } return $new_values; } /** * Relay an entry an update has just completed. * * @param int $entry_id The entry's ID. * @param int $form_id The form's ID. * @return void */ public function handle_update( $entry_id, $form_id = 0 ) { $entry_id = (int) $entry_id; $before = isset( $this->before_update[ $entry_id ] ) ? $this->before_update[ $entry_id ] : self::SUBMITTED; unset( $this->before_update[ $entry_id ] ); if ( self::SUBMITTED !== $before ) { $this->relay( $entry_id ); } } /** * Relay an entry marked as not spam. * * @param int $entry_id The entry's ID. * @return void */ public function handle_not_spam( $entry_id ) { $this->relay( $entry_id ); } /** * Hand a complete entry to the relay, once per entry across requests. * * @param int $entry_id The entry's ID. * @return void */ private function relay( $entry_id ) { try { $entry_id = (int) $entry_id; if ( ! $entry_id || ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) || ! class_exists( 'FrmEntry' ) ) { return; } $entry = \FrmEntry::getOne( $entry_id, true ); if ( ! is_object( $entry ) || self::SUBMITTED !== (int) $entry->is_draft || ! empty( $entry->parent_item_id ) ) { return; } // Forms that are not sent to LaunchList are left untouched: no work, no entry marker. if ( ! $this->routed( (string) $entry->form_id ) || $this->was_relayed( $entry_id ) || ! $this->claim( 'entry:' . $entry_id ) ) { return; } $metas = isset( $entry->metas ) && is_array( $entry->metas ) ? $entry->metas : array(); $values = array(); foreach ( $this->answers( $entry->form_id, false ) as $answer ) { $value = isset( $metas[ $answer['field_id'] ] ) ? $metas[ $answer['field_id'] ] : ''; if ( '' !== $answer['part'] ) { $value = is_array( $value ) && isset( $value[ $answer['part'] ] ) ? $value[ $answer['part'] ] : ''; } self::push( $values, $answer['name'], $answer['label'], $answer['type'], $value ); } $this->send( (string) $entry->form_id, isset( $entry->form_name ) ? self::plain( $entry->form_name ) : '', $values ); $this->mark_relayed( $entry_id ); } catch ( \Throwable $e ) { $this->fail( $e ); } } /** * Whether an entry carries the relayed marker. * * @param int $entry_id The entry's ID. * @return bool */ private function was_relayed( $entry_id ) { global $wpdb; $count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}frm_item_metas WHERE item_id = %d AND field_id = %d AND meta_value LIKE %s", $entry_id, 0, '%' . $wpdb->esc_like( self::RELAYED_KEY ) . '%' ) ); return (int) $count > 0; } /** * Mark an entry as relayed, the way Formidable stores its own `captcha_score`. * * @param int $entry_id The entry's ID. * @return void */ private function mark_relayed( $entry_id ) { if ( class_exists( 'FrmEntryMeta' ) ) { \FrmEntryMeta::add_entry_meta( $entry_id, 0, '', maybe_serialize( array( self::RELAYED_KEY => time() ) ) ); } } /** * A form's answers, in form order: one per field, one per part of a Name or Address field. * * @param int|string $form_id The form's ID. * @param bool $translate Whether labels are for the settings screen (else plain English). * @return array */ private function answers( $form_id, $translate ) { $answers = array(); if ( ! class_exists( 'FrmField' ) || ! (int) $form_id ) { return $answers; } // Fields of this form only: a repeater's fields belong to its child entries. foreach ( (array) \FrmField::get_all_for_form( (int) $form_id, '', 'exclude', 'exclude' ) as $field ) { if ( ! is_object( $field ) || empty( $field->field_key ) || ! isset( self::TYPE_MAP[ (string) $field->type ] ) ) { continue; } $type = (string) $field->type; $key = (string) $field->field_key; $label = self::plain( $field->name ); $label = '' !== $label ? $label : $key; $options = isset( $field->field_options ) && is_array( $field->field_options ) ? $field->field_options : array(); if ( in_array( $type, self::COMPOSITE, true ) ) { foreach ( $this->parts( $type, $options, $translate ) as $part => $default_label ) { $part_label = isset( $options[ $part . '_desc' ] ) ? self::plain( $options[ $part . '_desc' ] ) : ''; $answers[] = array( 'name' => $key . '.' . $part, 'label' => self::part_label( $label, '' !== $part_label ? $part_label : $default_label ), 'type' => 'text', 'field_id' => (string) $field->id, 'part' => $part, ); } continue; } $answers[] = array( 'name' => $key, 'label' => $label, 'type' => self::TYPE_MAP[ $type ], 'field_id' => (string) $field->id, 'part' => '', ); } return $answers; } /** * The parts of a Name field (in its chosen layout) or an Address field, with default labels used * when the founder kept Formidable's own: translated for the settings screen, English when submitted. * * @param string $type `name` or `address`. * @param array $options The field's options. * @param bool $translate Whether the labels are for the settings screen. * @return array */ private function parts( $type, array $options, $translate ) { if ( 'address' === $type ) { $parts = $translate ? array( 'line1' => __( 'Line 1', 'launchlist' ), 'line2' => __( 'Line 2', 'launchlist' ), 'city' => __( 'City', 'launchlist' ), 'state' => __( 'State/Province', 'launchlist' ), 'zip' => __( 'Zip/Postal', 'launchlist' ), 'country' => __( 'Country', 'launchlist' ), ) : array( 'line1' => 'Line 1', 'line2' => 'Line 2', 'city' => 'City', 'state' => 'State/Province', 'zip' => 'Zip/Postal', 'country' => 'Country', ); // A US address has no country input. if ( isset( $options['address_type'] ) && 'us' === $options['address_type'] ) { unset( $parts['country'] ); } return $parts; } $defaults = $translate ? array( 'first' => __( 'First Name', 'launchlist' ), 'middle' => __( 'Middle Name', 'launchlist' ), 'last' => __( 'Last Name', 'launchlist' ), ) : array( 'first' => 'First Name', 'middle' => 'Middle Name', 'last' => 'Last Name', ); $layout = ! empty( $options['name_layout'] ) ? (string) $options['name_layout'] : 'first_last'; $parts = array(); foreach ( explode( '_', $layout ) as $part ) { if ( isset( $defaults[ $part ] ) ) { $parts[ $part ] = $defaults[ $part ]; } } return $parts; } }