'text', 'textarea' => 'textarea', 'email' => 'email', 'phone' => 'tel', 'website' => 'url', 'number' => 'number', 'date' => 'date', 'time' => 'text', 'select' => 'select', 'multiselect' => 'select', 'radio' => 'radio', 'checkbox' => 'checkbox', 'consent' => 'checkbox', 'hidden' => 'hidden', 'name' => 'text', 'address' => 'text', 'post_title' => 'text', 'post_content' => 'textarea', 'post_excerpt' => 'textarea', 'post_tags' => 'text', ); /** * {@inheritDoc} */ public function slug() { return 'gravity-forms'; } /** * {@inheritDoc} */ public function name() { return 'Gravity Forms'; } /** * {@inheritDoc} */ public function is_available() { return class_exists( 'GFForms' ) && class_exists( 'GFAPI' ); } /** * {@inheritDoc} */ public function forms() { global $wpdb; if ( ! class_exists( 'GFFormsModel' ) || ! method_exists( 'GFFormsModel', 'get_form_table_name' ) ) { return array(); } // GFAPI::get_forms() decodes every form's meta and GFFormsModel::get_forms() counts every // entry and view; the form table alone holds the ID and title. $table = esc_sql( \GFFormsModel::get_form_table_name() ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, title FROM {$table} WHERE is_trash = %d ORDER BY title ASC LIMIT %d", 0, self::MAX_FORMS ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $forms = array(); foreach ( (array) $rows as $row ) { $forms[] = array( 'id' => (string) $row->id, 'title' => self::plain( $row->title ), ); } return $forms; } /** * {@inheritDoc} */ public function fields( $form_id ) { if ( ! class_exists( 'GFAPI' ) || ! (int) $form_id ) { return array(); } $fields = array(); foreach ( $this->answers( \GFAPI::get_form( (int) $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; } /* * `gform_after_submission` ($entry, $form). GFFormDisplay::process_form() fires it once the * submission is valid (validation, honeypot and captcha passed), the entry is saved and the * notifications were attempted, for AJAX, plain POST and GFAPI::submit_form() submissions alike; * never for a save-and-continue or a `gform_abort_submission_with_confirmation` abort. It also * fires for entries handle_submission() marked as spam (documented), so `status` is checked. */ add_action( 'gform_after_submission', array( $this, 'handle' ), 10, 2 ); /* * `gform_update_status` ($entry_id, $new_status, $previous_status), fired by * GFFormsModel::update_entry_property() when an entry's status changes: the entry list's * "Not Spam" action among others. A spam entry moved to active is relayed then, once. */ add_action( 'gform_update_status', array( $this, 'handle_status' ), 10, 3 ); } /** * Relay one Gravity Forms entry at submission. * * @param array $entry The saved entry. * @param array $form The form. * @return void */ public function handle( $entry, $form ) { try { if ( ! is_array( $entry ) ) { return; } $status = isset( $entry['status'] ) ? (string) $entry['status'] : 'active'; if ( 'active' === $status || '' === $status ) { $this->relay( $entry, $form ); } } catch ( \Throwable $e ) { $this->fail( $e ); } } /** * Relay an entry moved from spam to active. * * @param int $entry_id The entry's ID. * @param string $status Its new status. * @param string $previous_status Its status before. * @return void */ public function handle_status( $entry_id, $status = '', $previous_status = '' ) { try { if ( 'active' !== $status || 'spam' !== $previous_status || ! (int) $entry_id || ! class_exists( 'GFAPI' ) ) { return; } $entry = \GFAPI::get_entry( (int) $entry_id ); if ( ! is_array( $entry ) || empty( $entry['form_id'] ) ) { return; } $this->relay( $entry, \GFAPI::get_form( (int) $entry['form_id'] ) ); } catch ( \Throwable $e ) { $this->fail( $e ); } } /** * Hand an entry to the relay, once per entry across requests. * * @param array $entry The entry. * @param mixed $form The form. * @return void */ private function relay( array $entry, $form ) { if ( ! is_array( $form ) || empty( $form['id'] ) ) { return; } $form_id = (string) $form['id']; $entry_id = ! empty( $entry['id'] ) ? (int) $entry['id'] : 0; // Forms that are not sent to LaunchList are left untouched: no work, no entry marker. if ( ! $this->routed( $form_id ) ) { return; } $key = $entry_id ? (string) $entry_id : md5( serialize( $entry ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize if ( ! $this->claim( 'entry:' . $form_id . ':' . $key ) ) { return; } if ( $entry_id && function_exists( 'gform_get_meta' ) && gform_get_meta( $entry_id, self::RELAYED_META ) ) { return; } $values = array(); foreach ( $this->answers( $form, false ) as $answer ) { self::push( $values, $answer['name'], $answer['label'], $answer['type'], $this->entry_value( $entry, $answer ) ); } $this->send( $form_id, isset( $form['title'] ) ? self::plain( $form['title'] ) : '', $values ); if ( $entry_id && function_exists( 'gform_update_meta' ) ) { gform_update_meta( $entry_id, self::RELAYED_META, time(), (int) $form_id ); } } /** * A form's answers, in form order: one per single-input field, one per visible sub-input of a Name * or Address field, one list per checkbox field. * * @param mixed $form The form, with GF_Field objects in `fields`. * @param bool $translate Whether labels are for the settings screen (else plain English). * @return array */ private function answers( $form, $translate ) { $answers = array(); if ( ! is_array( $form ) || empty( $form['fields'] ) || ! is_array( $form['fields'] ) ) { return $answers; } foreach ( $form['fields'] as $field ) { if ( ! is_object( $field ) ) { continue; } $type = (string) $field->type; $input_type = method_exists( $field, 'get_input_type' ) ? (string) $field->get_input_type() : $type; if ( ! isset( self::TYPE_MAP[ $input_type ] ) || in_array( $type, self::SKIP_TYPES, true ) || 'administrative' === $field->visibility ) { continue; } $label = $this->label( $field, $translate ); $kind = $this->kind( $field, $input_type ); if ( 'inputs' === $kind ) { foreach ( (array) $field->get_entry_inputs() as $input ) { if ( ! is_array( $input ) || ! isset( $input['id'] ) || ! empty( $input['isHidden'] ) ) { continue; } $sub = ! empty( $input['customLabel'] ) ? $input['customLabel'] : ( isset( $input['label'] ) ? $input['label'] : $input['id'] ); $answers[] = array( 'name' => (string) $input['id'], 'label' => self::part_label( $label, self::plain( $sub ) ), 'type' => 'text', 'kind' => 'single', 'field' => $field, ); } continue; } $answers[] = array( 'name' => (string) $field->id, 'label' => $label, 'type' => self::TYPE_MAP[ $input_type ], 'kind' => $kind, 'field' => $field, ); } return $answers; } /** * How a field's value is stored in the entry. * * @param object $field The GF_Field. * @param string $input_type Its input type. * @return string `single` (entry[id]), `inputs` (entry[id.n] per part), `choices` (entry[id.n] per * checked box), `multiselect` (JSON or comma list in entry[id]) or `consent` (entry[id.1]). */ private function kind( $field, $input_type ) { if ( 'consent' === $input_type ) { return 'consent'; } if ( 'checkbox' === $input_type ) { return 'choices'; } if ( 'multiselect' === $input_type ) { return 'multiselect'; } // Email (with confirmation), date and time keep `inputs` for display but store one value. $inputs = method_exists( $field, 'get_entry_inputs' ) ? $field->get_entry_inputs() : null; return is_array( $inputs ) && $inputs ? 'inputs' : 'single'; } /** * One answer's value in an entry. * * @param array $entry The entry. * @param array $answer The answer, from answers(). * @return string|array */ private function entry_value( array $entry, array $answer ) { $field = $answer['field']; $id = (string) $field->id; switch ( $answer['kind'] ) { case 'choices': $checked = array(); foreach ( (array) $field->get_entry_inputs() as $input ) { $input_id = is_array( $input ) && isset( $input['id'] ) ? (string) $input['id'] : ''; if ( '' !== $input_id && isset( $entry[ $input_id ] ) && '' !== (string) $entry[ $input_id ] ) { $checked[] = $entry[ $input_id ]; } } return $checked; case 'multiselect': $raw = isset( $entry[ $id ] ) ? (string) $entry[ $id ] : ''; if ( '' === $raw ) { return array(); } if ( method_exists( $field, 'to_array' ) ) { return (array) $field->to_array( $raw ); } $list = '[' === $raw[0] ? json_decode( $raw, true ) : explode( ',', $raw ); return is_array( $list ) ? $list : array(); case 'consent': return isset( $entry[ $id . '.1' ] ) ? $entry[ $id . '.1' ] : ''; default: return isset( $entry[ $answer['name'] ] ) ? $entry[ $answer['name'] ] : ''; } } /** * A field's label, else its admin label, else a stand-in: translated for the settings screen, * English when submitted. * * @param object $field The GF_Field. * @param bool $translate Whether the stand-in is for the settings screen. * @return string */ private function label( $field, $translate ) { $label = self::plain( $field->label ); if ( '' === $label ) { $label = self::plain( $field->adminLabel ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase } if ( '' !== $label ) { return $label; } /* translators: %s: a Gravity Forms field ID. */ return $translate ? sprintf( __( 'Field %s', 'launchlist' ), $field->id ) : sprintf( 'Field %s', $field->id ); } }