$label ) { if ( 'date' === $key ) { $result['aofb_shortcode'] = __( 'Shortcode', 'admin-optimizer' ); } $result[ $key ] = $label; } if ( ! isset( $result['aofb_shortcode'] ) ) { $result['aofb_shortcode'] = __( 'Shortcode', 'admin-optimizer' ); } return $result; } /** * Display the form's embed shortcode. * * @param string|null $column Column name. * @param int|null $post_id Form post ID. * @return void */ public function render_shortcode_column( ?string $column, ?int $post_id ): void { if ( 'aofb_shortcode' !== $column || ! absint( $post_id ) ) { return; } echo '' . esc_html( sprintf( '[ao_form id="%d"]', absint( $post_id ) ) ) . ''; } /** * Load the AI generation upgrade affordance on the Forms list screen. */ public function enqueue_ai_upgrade_assets() { if ( ! $this->is_forms_list_screen() ) { return; } wp_enqueue_style( 'adminoptimizer-form-builder-ai-upgrade', Form_Builder::MODULE_URL . 'assets/css/form-builder-ai-upgrade.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptimizer-form-builder-ai-upgrade', Form_Builder::MODULE_URL . 'assets/js/form-builder-ai-upgrade.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptimizer-form-builder-ai-upgrade', 'aofbAiUpgradeData', [ 'buttonLabel' => __( 'Generate with AI', 'admin-optimizer' ), ] ); } /** * Render the Free-edition upgrade dialog without exposing an AI workflow. */ public function render_ai_upgrade_modal() { if ( ! $this->is_forms_list_screen() || ! Form_Builder::can_manage_forms() ) { return; } ?> > $fields Field tree. * * @return bool */ private static function field_tree_has_active_conditions( array $fields ) { foreach ( $fields as $field ) { $conditions = isset( $field['conditions'] ) && is_array( $field['conditions'] ) ? $field['conditions'] : []; if ( ! empty( $conditions['enabled'] ) ) { return true; } if ( 'row' === (string) ( $field['type'] ?? '' ) && ! empty( $field['children'] ) && is_array( $field['children'] ) && self::field_tree_has_active_conditions( $field['children'] ) ) { return true; } } return false; } /** * Check the exact Forms list screen. * * @return bool */ private function is_forms_list_screen() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && 'edit-adminoptim-form' === $screen->id; } /** * Validate, sanitize, and persist a form save payload from the REST API. * Nonce/capability verification is the REST controller's job (its * `permission_callback` plus WordPress's own REST nonce check); this * method assumes the caller is already authorized. * * Nothing is written unless validation passes — a failing save must never * leave the form partially updated. * * @param int $form_id Form post ID. * @param array $payload Raw payload: `title`, `status`, `fields`, `settings`. * * @return array|\WP_Error Normalized saved data on success, or a * `WP_Error` (`errors` in its data) on failure. */ public function save_form_data( $form_id, array $payload ) { $form_id = absint( $form_id ); if ( ! Form_Builder::can_edit_form( $form_id ) ) { return new \WP_Error( 'aofb_forbidden', __( 'Access denied.', 'admin-optimizer' ), [ 'status' => 403 ] ); } $raw_title = isset( $payload['title'] ) ? (string) $payload['title'] : ''; $status = isset( $payload['status'] ) ? sanitize_key( (string) $payload['status'] ) : 'draft'; if ( ! in_array( $status, [ 'publish', 'draft' ], true ) ) { $status = 'draft'; } $type = get_post_type_object( Form_Builder::ADMIN_POST_TYPE ); if ( 'publish' === $status && ! current_user_can( $type->cap->publish_posts ) ) { return new \WP_Error( 'aofb_forbidden', __( 'Publishing is not permitted.', 'admin-optimizer' ), [ 'status' => 403 ] ); } $raw_fields = isset( $payload['fields'] ) && is_array( $payload['fields'] ) ? array_values( $payload['fields'] ) : []; $old_fields = self::get_form_fields( $form_id ); $fields = $this->sanitize_form_fields_tree( $raw_fields, false, $old_fields ); $raw_settings = isset( $payload['settings'] ) && is_array( $payload['settings'] ) ? $payload['settings'] : []; $settings = $this->build_form_settings( $form_id, $raw_settings, $fields ); if ( is_wp_error( $settings ) ) { $errors = $settings->get_error_data(); $errors = is_array( $errors ) ? $errors : []; return new \WP_Error( 'aofb_validation', $settings->get_error_message(), [ 'status' => 422, 'errors' => $errors, ] ); } $errors = $this->validate_form_save( $raw_title, $fields, $settings ); if ( ! empty( $errors ) ) { return new \WP_Error( 'aofb_validation', __( 'Fix the highlighted problems before saving.', 'admin-optimizer' ), [ 'status' => 422, 'errors' => $errors, ] ); } wp_update_post( [ 'ID' => $form_id, 'post_title' => sanitize_text_field( $raw_title ), 'post_status' => $status, ] ); update_post_meta( $form_id, Form_Builder::META_FIELDS, $fields ); update_post_meta( $form_id, Form_Builder::META_SETTINGS, $settings ); return [ 'title' => sanitize_text_field( $raw_title ), 'status' => $status, 'fields' => $fields, 'settings' => $settings, ]; } /** * Validate a save payload against its already-sanitized fields/settings. * Every error carries a `tab` so the builder UI can route the user to * the panel that needs attention, plus either a `field_id`/`key` pair * (field-level errors) or a `focus` hint (form-level errors). * * @param string $title Raw title. * @param array> $fields Sanitized field tree. * @param array $settings Built settings array. * * @return array> */ private function validate_form_save( $title, array $fields, array $settings ) { $errors = []; if ( '' === trim( (string) $title ) ) { $errors[] = [ 'tab' => 'builder', 'focus' => 'title', 'message' => __( 'Add a form title.', 'admin-optimizer' ), ]; } $seen_names = []; $this->validate_form_fields_tree( $fields, $errors, $seen_names ); if ( ! empty( $settings['enable_autoresponder'] ) && ! $this->form_fields_tree_has_type( $fields, 'email' ) ) { $errors[] = [ 'tab' => 'emails', 'focus' => 'autoresponder_enabled', 'message' => __( 'The autoresponder needs an Email field to send to. Add one on the Builder tab, or turn the autoresponder off.', 'admin-optimizer' ), ]; } $mail_active = in_array( $settings['delivery_mode'], [ 'email_and_store', 'email_only' ], true ); if ( $mail_active && '' !== trim( (string) $settings['mail_to'] ) && ! is_email( $settings['mail_to'] ) ) { $errors[] = [ 'tab' => 'emails', 'focus' => 'mail_to', 'message' => __( 'Enter a valid email address for notifications.', 'admin-optimizer' ), ]; } return $errors; } /** * Recursively validate labels, names, and choices across a sanitized * field tree, tracking a `path` (nesting index chain) so an error can * point a UI back at the exact field/Row. * * @param array> $fields Sanitized field tree. * @param array> $errors By-ref error accumulator. * @param array $seen_names By-ref name => seen accumulator (form-wide, across nesting). * @param string $path Dotted index-path prefix for nested fields. * * @return void */ private function validate_form_fields_tree( array $fields, array &$errors, array &$seen_names, $path = '' ) { foreach ( $fields as $index => $field ) { $type = (string) ( $field['type'] ?? '' ); if ( 'row' === $type ) { if ( ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $this->validate_form_fields_tree( $field['children'], $errors, $seen_names, $path . $index . '.' ); } continue; } if ( in_array( $type, [ 'html_message', 'recaptcha', 'turnstile', 'submit' ], true ) ) { continue; } $field_id = $path . $index; $label = isset( $field['label'] ) ? (string) $field['label'] : ''; $name = isset( $field['name'] ) ? (string) $field['name'] : ''; if ( Module_Registry::is_pro_active() ) { $limits = self::get_image_dimension_limits( $field ); foreach ( [ 'width', 'height' ] as $dimension ) { if ( isset( $limits[ "min_{$dimension}" ], $limits[ "max_{$dimension}" ] ) && $limits[ "min_{$dimension}" ] > $limits[ "max_{$dimension}" ] ) { $errors[] = [ 'tab' => 'builder', 'field_id' => $field_id, 'key' => "image_max_{$dimension}", 'message' => sprintf( /* translators: %s: field label. */ __( '"%s" has a minimum image size larger than its maximum.', 'admin-optimizer' ), '' !== $label ? $label : __( 'Untitled field', 'admin-optimizer' ) ), ]; } } } if ( '' === trim( $label ) ) { $errors[] = [ 'tab' => 'builder', 'field_id' => $field_id, 'key' => 'label', 'message' => __( 'A field has no label.', 'admin-optimizer' ), ]; } if ( ! preg_match( '/^[a-z][a-z0-9_]*$/', $name ) ) { $errors[] = [ 'tab' => 'builder', 'field_id' => $field_id, 'key' => 'name', 'message' => sprintf( /* translators: %s: field label. */ __( '"%s" needs a name with lowercase letters, numbers, and underscores.', 'admin-optimizer' ), '' !== $label ? $label : __( 'Untitled field', 'admin-optimizer' ) ), ]; } elseif ( isset( $seen_names[ $name ] ) ) { $errors[] = [ 'tab' => 'builder', 'field_id' => $field_id, 'key' => 'name', 'message' => sprintf( /* translators: %s: field name. */ __( 'The name "%s" is used twice. Names must be unique.', 'admin-optimizer' ), $name ), ]; } else { $seen_names[ $name ] = true; } if ( Fields::supports_choices( $type ) && '' === trim( (string) ( $field['choices'] ?? '' ) ) ) { $errors[] = [ 'tab' => 'builder', 'field_id' => $field_id, 'field_name' => $name, 'key' => 'choices', 'message' => sprintf( /* translators: %s: field label. */ __( 'Add at least one choice to "%s".', 'admin-optimizer' ), '' !== $label ? $label : __( 'Untitled field', 'admin-optimizer' ) ), ]; } } } /** * Recursively check whether a field tree contains a field of the given type. * * @param array> $fields Field tree. * @param string $type Field type to look for. * * @return bool */ private function form_fields_tree_has_type( array $fields, $type ) { foreach ( $fields as $field ) { if ( 'row' === (string) ( $field['type'] ?? '' ) ) { if ( ! empty( $field['children'] ) && is_array( $field['children'] ) && $this->form_fields_tree_has_type( $field['children'], $type ) ) { return true; } continue; } if ( $type === (string) ( $field['type'] ?? '' ) ) { return true; } } return false; } /** * Sanitize a fields tree for the REST preview route — the same sanitize * pass a save runs, without any of `save_form_data()`'s persistence or * validation. Exposed as a thin public wrapper since the REST controller * lives in a separate class. * * @param array> $raw_fields Raw, unsaved field tree. * * @return array> */ public function sanitize_fields_for_preview( array $raw_fields ) { return $this->sanitize_form_fields_tree( $raw_fields, false, [] ); } /** * Build the settings array for a save from a raw settings payload, * applying the same field-by-field sanitization and the Pro-gated * fallback-to-old-value behavior that existed in the metabox save path: * Submission Limits / Access Control / Redirect URL keys fall back to * the previously stored value (instead of a hard default) whenever the * payload omits them, mirroring "a disabled input submits nothing" so a * Pro -> inactive re-save never silently wipes settings configured while * Pro was active. * * @param int $form_id Form post ID. * @param array $raw_settings Raw settings payload. * @param array> $fields Already-sanitized field tree, passed through to the * `adminoptim_form_builder_sanitize_integrations` filter. * * @return array|\WP_Error */ private function build_form_settings( $form_id, array $raw_settings, array $fields = [] ) { $delivery_mode = isset( $raw_settings['delivery_mode'] ) ? sanitize_key( $raw_settings['delivery_mode'] ) : 'email_and_store'; if ( ! in_array( $delivery_mode, [ 'email_and_store', 'email_only', 'store_only' ], true ) ) { $delivery_mode = 'email_and_store'; } $defaults = self::get_default_form_settings(); $is_pro_active = Module_Registry::is_pro_active(); $old_settings = self::get_form_settings( $form_id ); $old_integrations = isset( $old_settings['integrations'] ) && is_array( $old_settings['integrations'] ) ? $old_settings['integrations'] : []; $raw_has_integrations = array_key_exists( 'integrations', $raw_settings ); $close_raw_present = isset( $raw_settings['submission_close_date'] ) || isset( $raw_settings['submission_close_time'] ) || isset( $raw_settings['submission_close_at'] ); $settings = [ 'submit_label' => isset( $raw_settings['submit_label'] ) && '' !== trim( (string) $raw_settings['submit_label'] ) ? sanitize_text_field( $raw_settings['submit_label'] ) : $defaults['submit_label'], 'submit_css_class' => isset( $raw_settings['submit_css_class'] ) ? sanitize_html_class( $raw_settings['submit_css_class'] ) : '', 'success_message' => isset( $raw_settings['success_message'] ) && '' !== trim( (string) $raw_settings['success_message'] ) ? sanitize_textarea_field( $raw_settings['success_message'] ) : $defaults['success_message'], 'success_behavior' => isset( $raw_settings['success_behavior'] ) && 'replace' === sanitize_key( (string) $raw_settings['success_behavior'] ) ? 'replace' : 'message', 'delivery_mode' => $delivery_mode, 'mail_to' => isset( $raw_settings['mail_to'] ) && '' !== trim( (string) $raw_settings['mail_to'] ) ? sanitize_text_field( $raw_settings['mail_to'] ) : $defaults['mail_to'], 'mail_from_name' => isset( $raw_settings['mail_from_name'] ) && '' !== trim( (string) $raw_settings['mail_from_name'] ) ? sanitize_text_field( $raw_settings['mail_from_name'] ) : $defaults['mail_from_name'], 'mail_from_email' => isset( $raw_settings['mail_from_email'] ) && '' !== trim( (string) $raw_settings['mail_from_email'] ) ? sanitize_text_field( $raw_settings['mail_from_email'] ) : $defaults['mail_from_email'], 'mail_reply_to' => isset( $raw_settings['mail_reply_to'] ) ? sanitize_text_field( $raw_settings['mail_reply_to'] ) : '', 'mail_smtp_profile' => $this->sanitize_smtp_profile_setting( $raw_settings['mail_smtp_profile'] ?? '' ), 'mail_subject' => isset( $raw_settings['mail_subject'] ) && '' !== trim( (string) $raw_settings['mail_subject'] ) ? sanitize_text_field( $raw_settings['mail_subject'] ) : $defaults['mail_subject'], 'mail_message' => isset( $raw_settings['mail_message'] ) && '' !== trim( (string) $raw_settings['mail_message'] ) ? wp_kses_post( $raw_settings['mail_message'] ) : $defaults['mail_message'], 'mail_send_as_html' => ! empty( $raw_settings['mail_send_as_html'] ) ? 1 : 0, 'enable_autoresponder' => ! empty( $raw_settings['enable_autoresponder'] ) ? 1 : 0, 'autoresponder_to_field' => isset( $raw_settings['autoresponder_to_field'] ) ? sanitize_key( $raw_settings['autoresponder_to_field'] ) : '', 'autoresponder_smtp_profile' => $this->sanitize_smtp_profile_setting( $raw_settings['autoresponder_smtp_profile'] ?? '' ), 'autoresponder_subject' => isset( $raw_settings['autoresponder_subject'] ) && '' !== trim( (string) $raw_settings['autoresponder_subject'] ) ? sanitize_text_field( $raw_settings['autoresponder_subject'] ) : $defaults['autoresponder_subject'], 'autoresponder_message' => isset( $raw_settings['autoresponder_message'] ) && '' !== trim( (string) $raw_settings['autoresponder_message'] ) ? wp_kses_post( $raw_settings['autoresponder_message'] ) : $defaults['autoresponder_message'], 'autoresponder_send_as_html' => ! empty( $raw_settings['autoresponder_send_as_html'] ) ? 1 : 0, 'disable_frontend_css' => ! empty( $raw_settings['disable_frontend_css'] ) ? 1 : 0, 'enable_honeypot' => ! empty( $raw_settings['enable_honeypot'] ) ? 1 : 0, 'redirect_url' => isset( $raw_settings['redirect_url'] ) ? esc_url_raw( trim( (string) $raw_settings['redirect_url'] ) ) : ( $is_pro_active ? '' : $old_settings['redirect_url'] ), 'submission_limit_max_entries' => isset( $raw_settings['submission_limit_max_entries'] ) && absint( $raw_settings['submission_limit_max_entries'] ) ? absint( $raw_settings['submission_limit_max_entries'] ) : ( $is_pro_active ? '' : $old_settings['submission_limit_max_entries'] ), 'submission_close_at' => ( $is_pro_active || $close_raw_present ) ? $this->sanitize_close_datetime_settings( $raw_settings ) : $old_settings['submission_close_at'], 'submission_closed_message' => isset( $raw_settings['submission_closed_message'] ) && '' !== trim( (string) $raw_settings['submission_closed_message'] ) ? sanitize_textarea_field( $raw_settings['submission_closed_message'] ) : ( $is_pro_active ? $defaults['submission_closed_message'] : $old_settings['submission_closed_message'] ), 'access_mode' => isset( $raw_settings['access_mode'] ) ? sanitize_key( $raw_settings['access_mode'] ) : ( $is_pro_active ? $defaults['access_mode'] : $old_settings['access_mode'] ), 'access_roles' => isset( $raw_settings['access_roles'] ) || $is_pro_active ? $this->sanitize_access_roles( $raw_settings['access_roles'] ?? [] ) : $old_settings['access_roles'], 'access_denied_message' => isset( $raw_settings['access_denied_message'] ) && '' !== trim( (string) $raw_settings['access_denied_message'] ) ? sanitize_textarea_field( $raw_settings['access_denied_message'] ) : ( $is_pro_active ? $defaults['access_denied_message'] : $old_settings['access_denied_message'] ), 'integrations' => $is_pro_active ? $old_integrations : $old_integrations, 'style_enabled' => array_key_exists( 'style_enabled', $raw_settings ) ? ( ! empty( $raw_settings['style_enabled'] ) ? 1 : 0 ) : (int) ! empty( $old_settings['style_enabled'] ), 'style_primary_color' => isset( $raw_settings['style_primary_color'] ) ? sanitize_hex_color( $raw_settings['style_primary_color'] ) : '', 'style_text_color' => isset( $raw_settings['style_text_color'] ) ? sanitize_hex_color( $raw_settings['style_text_color'] ) : '', 'style_bg_color' => isset( $raw_settings['style_bg_color'] ) ? sanitize_hex_color( $raw_settings['style_bg_color'] ) : '', 'style_border_color' => isset( $raw_settings['style_border_color'] ) ? sanitize_hex_color( $raw_settings['style_border_color'] ) : '', 'style_border_radius' => isset( $raw_settings['style_border_radius'] ) && '' !== $raw_settings['style_border_radius'] ? absint( $raw_settings['style_border_radius'] ) : '', 'style_msg_success_bg' => isset( $raw_settings['style_msg_success_bg'] ) ? sanitize_hex_color( $raw_settings['style_msg_success_bg'] ) : '', 'style_msg_success_text' => isset( $raw_settings['style_msg_success_text'] ) ? sanitize_hex_color( $raw_settings['style_msg_success_text'] ) : '', 'style_msg_success_border' => isset( $raw_settings['style_msg_success_border'] ) ? sanitize_hex_color( $raw_settings['style_msg_success_border'] ) : '', 'style_msg_error_bg' => isset( $raw_settings['style_msg_error_bg'] ) ? sanitize_hex_color( $raw_settings['style_msg_error_bg'] ) : '', 'style_msg_error_text' => isset( $raw_settings['style_msg_error_text'] ) ? sanitize_hex_color( $raw_settings['style_msg_error_text'] ) : '', 'style_msg_error_border' => isset( $raw_settings['style_msg_error_border'] ) ? sanitize_hex_color( $raw_settings['style_msg_error_border'] ) : '', 'style_msg_border_radius' => isset( $raw_settings['style_msg_border_radius'] ) && '' !== $raw_settings['style_msg_border_radius'] ? absint( $raw_settings['style_msg_border_radius'] ) : '', 'style_msg_padding' => isset( $raw_settings['style_msg_padding'] ) && '' !== $raw_settings['style_msg_padding'] ? absint( $raw_settings['style_msg_padding'] ) : '', 'style_btn_bg' => isset( $raw_settings['style_btn_bg'] ) ? sanitize_hex_color( $raw_settings['style_btn_bg'] ) : '', 'style_btn_text' => isset( $raw_settings['style_btn_text'] ) ? sanitize_hex_color( $raw_settings['style_btn_text'] ) : '', 'style_btn_border' => isset( $raw_settings['style_btn_border'] ) ? sanitize_hex_color( $raw_settings['style_btn_border'] ) : '', 'style_btn_radius' => isset( $raw_settings['style_btn_radius'] ) && '' !== $raw_settings['style_btn_radius'] ? absint( $raw_settings['style_btn_radius'] ) : '', 'canvas_submit_button' => 1, ]; $submit_field = self::find_field_type( $fields, 'submit' ); if ( $submit_field && '' !== trim( (string) ( $submit_field['label'] ?? '' ) ) ) { // Preserve the legacy setting for extensions that still consume it. $settings['submit_label'] = sanitize_text_field( (string) $submit_field['label'] ); } if ( $is_pro_active && ( $raw_has_integrations || ! empty( $old_integrations ) ) ) { $integrations = apply_filters( 'adminoptim_form_builder_sanitize_integrations', $old_integrations, isset( $raw_settings['integrations'] ) && is_array( $raw_settings['integrations'] ) ? $raw_settings['integrations'] : [], $old_integrations, $fields ); if ( is_wp_error( $integrations ) ) { return $integrations; } $settings['integrations'] = is_array( $integrations ) ? $integrations : $old_integrations; } elseif ( ! $raw_has_integrations && empty( $old_integrations ) ) { // Defaults are runtime-only. Do not add an empty integrations key to // otherwise untouched form meta on an ordinary save. unset( $settings['integrations'] ); } if ( ! in_array( $settings['access_mode'], [ 'public', 'logged_in', 'roles' ], true ) ) { $settings['access_mode'] = $defaults['access_mode']; } if ( 'roles' !== $settings['access_mode'] ) { $settings['access_roles'] = []; } $settings['submission_limit_enabled'] = ( ! empty( $settings['submission_limit_max_entries'] ) || ! empty( $settings['submission_close_at'] ) ) ? 1 : 0; // Preserve any additional settings keys Free doesn't itself define // (e.g. Pro's Custom CSS `custom_css`/`custom_css_enabled`) exactly as // they were stored, so an ordinary save never silently drops them — // mirroring "a disabled input submits nothing" for an entire owner's // worth of settings rather than one key at a time. Keys Free DOES // define above take precedence over the stale old value. $settings = array_merge( $old_settings, $settings ); /** * Lets an extension (Pro) sanitize and persist its own additional * per-form settings keys into the save payload, without Free ever * needing to know those keys' names. Must be a no-op pass-through * when the extension providing those keys is inactive — the * array_merge() above already preserves whatever was last saved for * them in that case. * * @param array $settings Settings built so far (old values already merged in for unknown keys). * @param array $raw_settings Raw settings payload from the save request. * @param array $old_settings Previously stored settings. * @param array> $fields Already-sanitized field tree. * @param int $form_id Form post ID. */ $settings = apply_filters( 'adminoptim_form_builder_sanitize_extra_settings', $settings, $raw_settings, $old_settings, $fields, $form_id ); return $settings; } /** * Sanitize nested form field definitions. * * @param array> $raw_fields Raw field definitions. * @param bool $nested Whether these fields are inside a container. * @param array> $old_fields Previously stored sibling fields at this same * nesting level, used to fall back conditional-logic * rules to their last saved value when Pro is * inactive (its disabled builder panel never submits * a `conditions` value at all). * * @return array> */ private function sanitize_form_fields_tree( array $raw_fields, $nested = false, array $old_fields = [] ) { $fields = []; $antispam_field = null; $submit_seen = false; $is_pro_active = Module_Registry::is_pro_active(); $old_by_name = []; $old_rows = []; foreach ( $old_fields as $old_field ) { if ( ! is_array( $old_field ) ) { continue; } if ( 'row' === (string) ( $old_field['type'] ?? '' ) ) { $old_rows[] = $old_field; continue; } $old_name = (string) ( $old_field['name'] ?? '' ); if ( '' !== $old_name ) { $old_by_name[ $old_name ] = isset( $old_field['conditions'] ) && is_array( $old_field['conditions'] ) ? $old_field['conditions'] : []; } } $row_cursor = 0; foreach ( $raw_fields as $field ) { if ( ! is_array( $field ) ) { continue; } $type = isset( $field['type'] ) ? sanitize_key( (string) $field['type'] ) : 'text'; if ( ! in_array( $type, [ 'row', 'submit' ], true ) && empty( $field['name'] ) && ! empty( $field['label'] ) ) { $generated_name = str_replace( '-', '_', sanitize_title( wp_unslash( $field['label'] ) ) ); $field['name'] = '' !== $generated_name ? $generated_name : 'field_' . ( count( $fields ) + 1 ); } $raw_field = $field; $children = []; $legacy_columns = ( 'row' === $type && isset( $raw_field['settings']['columns'] ) ) ? (string) $raw_field['settings']['columns'] : ''; $old_row = ( 'row' === $type && ! $nested ) ? ( $old_rows[ $row_cursor ] ?? null ) : null; $old_children = is_array( $old_row ) && ! empty( $old_row['children'] ) && is_array( $old_row['children'] ) ? $old_row['children'] : []; if ( 'row' === $type && ! $nested && ! empty( $raw_field['children'] ) && is_array( $raw_field['children'] ) ) { $children = $this->sanitize_form_fields_tree( array_values( $raw_field['children'] ), true, $old_children ); $children = self::normalize_legacy_row_child_widths( $children, $legacy_columns ); } $field = Fields::sanitize_definition( $field ); if ( 'turnstile' === $type ) { $field['display_mode'] = 'invisible' === ( $raw_field['display_mode'] ?? '' ) ? 'invisible' : 'managed'; } // Star Rating is Pro-only; Fields::get_types() carries it unconditionally // as shared infrastructure, so it must be rejected here on save or a form // edited while Pro was active would keep rendering it after deactivation. if ( 'rating' === (string) $field['type'] && ! $is_pro_active ) { continue; } $field['children'] = $children; if ( $nested && 'hidden' === (string) $field['type'] ) { continue; } if ( $nested && 'row' !== $type ) { $field['width'] = Fields::sanitize_width( $raw_field['width'] ?? 'half' ); } else { unset( $field['width'] ); } if ( isset( $raw_field['conditions'] ) && is_array( $raw_field['conditions'] ) ) { $field['conditions'] = self::sanitize_raw_conditions( $raw_field['conditions'] ); } elseif ( $is_pro_active ) { $field['conditions'] = self::sanitize_raw_conditions( [] ); } elseif ( 'row' === $type ) { $field['conditions'] = is_array( $old_row ) && isset( $old_row['conditions'] ) && is_array( $old_row['conditions'] ) ? $old_row['conditions'] : self::sanitize_raw_conditions( [] ); } else { $lookup_name = ! empty( $field['name'] ) ? $field['name'] : ''; $field['conditions'] = isset( $old_by_name[ $lookup_name ] ) ? self::sanitize_raw_conditions( $old_by_name[ $lookup_name ] ) : self::sanitize_raw_conditions( [] ); } if ( 'row' === (string) $field['type'] ) { if ( $nested ) { continue; } $row_cursor++; $field['name'] = ''; $field['required'] = 0; $fields[] = $field; continue; } if ( 'submit' === (string) $field['type'] ) { if ( $submit_seen ) { continue; } $submit_seen = true; $field['name'] = ''; $field['label'] = '' !== trim( (string) $field['label'] ) ? $field['label'] : self::get_default_form_settings()['submit_label']; $field['required'] = 0; $field['children'] = []; $field['conditions'] = self::sanitize_raw_conditions( [] ); $fields[] = $field; continue; } if ( empty( $field['name'] ) && ! in_array( (string) $field['type'], [ 'html_message', 'recaptcha', 'turnstile' ], true ) ) { continue; } if ( in_array( (string) $field['type'], [ 'file', 'image' ], true ) ) { $field['allowed_file_types'] = isset( $raw_field['allowed_file_types'] ) ? sanitize_text_field( (string) $raw_field['allowed_file_types'] ) : ''; $field['max_file_size_mb'] = isset( $raw_field['max_file_size_mb'] ) && '' !== trim( (string) $raw_field['max_file_size_mb'] ) ? (string) max( 0, (float) $raw_field['max_file_size_mb'] ) : ''; $field['store_as_attachment'] = ! empty( $raw_field['store_as_attachment'] ) ? 1 : 0; $field = array_merge( $field, self::sanitize_image_dimensions( $raw_field ) ); } if ( in_array( (string) $field['type'], [ 'recaptcha', 'turnstile' ], true ) ) { if ( ! $nested && null === $antispam_field ) { $antispam_field = $field; } continue; } $fields[] = $field; } if ( null !== $antispam_field ) { $fields[] = $antispam_field; } $fields = array_values( $fields ); if ( ! $nested ) { $submit_seen = false; $fields = self::deduplicate_submit_items( $fields, $submit_seen ); $fields = self::finalize_conditions_tree( $fields ); } return $fields; } /** * Translate a legacy Row column count into direct-child widths. Old values * are normalized while loading/saving, without mutating post meta on read. * * @param array> $children Sanitized Row children. * @param string $columns Legacy column setting. * @return array> */ private static function normalize_legacy_row_child_widths( array $children, $columns ) { $columns = sanitize_key( (string) $columns ); if ( ! in_array( $columns, [ 'auto', '2', '3', '4' ], true ) || empty( $children ) ) { return $children; } if ( '2' === $columns ) { foreach ( $children as &$child ) { $child['width'] = 'half'; } unset( $child ); return $children; } if ( '3' === $columns ) { foreach ( $children as &$child ) { $child['width'] = 'three_quarter' === ( $child['width'] ?? '' ) ? 'two_thirds' : 'third'; } unset( $child ); return $children; } if ( '4' === $columns ) { foreach ( $children as &$child ) { $width = $child['width'] ?? ''; $child['width'] = in_array( $width, [ 'half', 'three_quarter' ], true ) ? $width : 'quarter'; } unset( $child ); return $children; } $full_children = []; $fixed_total = 0; $percentages = [ 'quarter' => 25, 'half' => 50, 'three_quarter' => 75 ]; foreach ( $children as $index => $child ) { $width = $child['width'] ?? 'full'; if ( 'full' === $width ) { $full_children[] = $index; } elseif ( isset( $percentages[ $width ] ) ) { $fixed_total += $percentages[ $width ]; } } $widths = [ 25 => 'quarter', 50 => 'half', 75 => 'three_quarter' ]; if ( 1 === count( $full_children ) && isset( $widths[ 100 - $fixed_total ] ) ) { $children[ $full_children[0] ]['width'] = $widths[ 100 - $fixed_total ]; } elseif ( count( $full_children ) === count( $children ) ) { $equal_width = [ 2 => 'half', 3 => 'third', 4 => 'quarter' ][ count( $children ) ] ?? 'full'; foreach ( $full_children as $index ) { $children[ $index ]['width'] = $equal_width; } } return $children; } /** * Sanitize a raw conditional-logic payload for one field/Row into its * canonical shape. Cross-field validity (self-reference, single-hop, * operator-per-type allowlist, orphaned targets) is enforced separately by * finalize_conditions_tree(), once the full sanitized tree is known. * * @param mixed $raw_conditions Raw `conditions` payload for one field. * * @return array{enabled:int,logic:string,rules:array>} */ private static function sanitize_raw_conditions( $raw_conditions ) { $raw_conditions = is_array( $raw_conditions ) ? $raw_conditions : []; $logic = isset( $raw_conditions['logic'] ) && 'any' === $raw_conditions['logic'] ? 'any' : 'all'; $rules = []; if ( ! empty( $raw_conditions['rules'] ) && is_array( $raw_conditions['rules'] ) ) { foreach ( $raw_conditions['rules'] as $rule ) { if ( ! is_array( $rule ) ) { continue; } $field_name = isset( $rule['field'] ) ? sanitize_key( (string) $rule['field'] ) : ''; $operator = isset( $rule['operator'] ) ? sanitize_key( (string) $rule['operator'] ) : 'equals'; if ( '' === $field_name || ! in_array( $operator, self::get_condition_operators(), true ) ) { continue; } $rules[] = [ 'field' => $field_name, 'operator' => $operator, 'value' => isset( $rule['value'] ) && is_scalar( $rule['value'] ) ? sanitize_text_field( (string) $rule['value'] ) : '', ]; } } return [ 'enabled' => ( ! empty( $raw_conditions['enabled'] ) && ! empty( $rules ) ) ? 1 : 0, 'logic' => $logic, 'rules' => $rules, ]; } /** * All condition-rule operators the builder ever accepts. Per-referenced- * field-type allowlisting narrows this further in finalize_field_conditions(). * * @return array */ private static function get_condition_operators() { return [ 'equals', 'not_equals', 'contains', 'not_contains', 'greater_than', 'less_than', 'is_empty', 'is_not_empty' ]; } /** * Operators allowed for a rule targeting a field of the given type. * * @param string $type Referenced field's type. * * @return array */ private static function get_condition_operators_for_type( $type ) { if ( in_array( $type, [ 'number', 'rating', 'datepicker' ], true ) ) { return [ 'equals', 'not_equals', 'greater_than', 'less_than', 'is_empty', 'is_not_empty' ]; } if ( 'check' === $type ) { return [ 'equals', 'not_equals', 'contains', 'not_contains', 'is_empty', 'is_not_empty' ]; } return [ 'equals', 'not_equals', 'is_empty', 'is_not_empty' ]; } /** * Enforce cross-field conditional-logic validity across an entire sanitized * field tree: drop rules referencing a nonexistent/orphaned field, a * recaptcha/turnstile field, the field's own name (self-reference), or a * field that itself has active conditions (single-hop enforcement — no * A -> B -> C chains, which also rejects a mutual A <-> B cycle since both * sides would otherwise need the other's conditions active); drop rules * whose operator isn't allowed for the referenced field's type. * * @param array> $fields Fully sanitized, structurally final field tree. * * @return array> */ private static function finalize_conditions_tree( array $fields ) { $reference_types = []; $enabled_map = []; self::collect_condition_reference_map( $fields, $reference_types, $enabled_map ); return self::apply_conditions_finalization( $fields, $reference_types, $enabled_map ); } /** * Recursively collect referenceable field names (name => type) and which * of them currently have active conditions of their own, skipping Row * containers (never referenceable) and recaptcha/turnstile (excluded as * condition targets). * * @param array> $fields Field tree. * @param array $types By-ref name => type accumulator. * @param array $enabled By-ref name => has-active-conditions accumulator. * * @return void */ private static function collect_condition_reference_map( array $fields, array &$types, array &$enabled ) { foreach ( $fields as $field ) { $type = (string) ( $field['type'] ?? '' ); if ( 'row' === $type ) { self::collect_condition_reference_map( (array) ( $field['children'] ?? [] ), $types, $enabled ); continue; } if ( in_array( $type, [ 'recaptcha', 'turnstile' ], true ) ) { continue; } $name = (string) ( $field['name'] ?? '' ); if ( '' === $name ) { continue; } $types[ $name ] = $type; $conditions = isset( $field['conditions'] ) && is_array( $field['conditions'] ) ? $field['conditions'] : []; $enabled[ $name ] = ! empty( $conditions['enabled'] ); } } /** * Recursively rewrite each field's `conditions` with the invalid rules removed. * * @param array> $fields Field tree. * @param array $reference_types Valid target field name => type. * @param array $enabled_map Target field name => has-active-conditions. * * @return array> */ private static function apply_conditions_finalization( array $fields, array $reference_types, array $enabled_map ) { foreach ( $fields as &$field ) { $own_name = 'row' === (string) ( $field['type'] ?? '' ) ? '' : (string) ( $field['name'] ?? '' ); $field['conditions'] = self::finalize_field_conditions( $field['conditions'] ?? [], $own_name, $reference_types, $enabled_map ); if ( 'row' === (string) ( $field['type'] ?? '' ) && ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $field['children'] = self::apply_conditions_finalization( $field['children'], $reference_types, $enabled_map ); } } unset( $field ); return $fields; } /** * Finalize one field's conditions against the whole-tree reference map. * * @param array $conditions Sanitized-shape conditions for this field. * @param string $own_name This field's own name (empty for a Row). * @param array $reference_types Valid target field name => type. * @param array $enabled_map Target field name => has-active-conditions. * * @return array{enabled:int,logic:string,rules:array>} */ private static function finalize_field_conditions( array $conditions, $own_name, array $reference_types, array $enabled_map ) { $rules = []; foreach ( (array) ( $conditions['rules'] ?? [] ) as $rule ) { if ( ! is_array( $rule ) ) { continue; } $target = (string) ( $rule['field'] ?? '' ); if ( '' === $target || $target === $own_name ) { continue; // Self-reference. } if ( ! isset( $reference_types[ $target ] ) ) { continue; // Nonexistent, orphaned, or recaptcha/turnstile target. } if ( ! empty( $enabled_map[ $target ] ) ) { continue; // Single-hop: the referenced field itself has active conditions. } if ( ! in_array( (string) ( $rule['operator'] ?? '' ), self::get_condition_operators_for_type( $reference_types[ $target ] ), true ) ) { continue; } $rules[] = $rule; } return [ 'enabled' => ( ! empty( $conditions['enabled'] ) && ! empty( $rules ) ) ? 1 : 0, 'logic' => isset( $conditions['logic'] ) && 'any' === $conditions['logic'] ? 'any' : 'all', 'rules' => $rules, ]; } /** * Sanitize split date/time settings into a local mysql datetime string. * * @param array $settings Raw settings. * * @return string */ private function sanitize_close_datetime_settings( array $settings ) { $date = isset( $settings['submission_close_date'] ) ? trim( sanitize_text_field( (string) $settings['submission_close_date'] ) ) : ''; $time = isset( $settings['submission_close_time'] ) ? trim( sanitize_text_field( (string) $settings['submission_close_time'] ) ) : ''; if ( '' !== $date ) { if ( '' === $time ) { $time = '23:59'; } return $this->sanitize_local_datetime( $date . ' ' . $time ); } return $this->sanitize_local_datetime( $settings['submission_close_at'] ?? '' ); } /** * Sanitize a local datetime value into a local mysql datetime string. * * @param mixed $datetime Raw datetime value. * * @return string */ private function sanitize_local_datetime( $datetime ) { $datetime = trim( sanitize_text_field( (string) $datetime ) ); if ( '' === $datetime ) { return ''; } $datetime = str_replace( 'T', ' ', $datetime ); if ( preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $datetime ) ) { $datetime .= ':00'; } if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $datetime ) ) { return ''; } $date = date_create_from_format( 'Y-m-d H:i:s', $datetime, wp_timezone() ); return $date ? $date->format( 'Y-m-d H:i:s' ) : ''; } /** * Sanitize selected role slugs. * * @param mixed $roles Raw roles. * * @return array */ private function sanitize_access_roles( $roles ) { $wp_roles = wp_roles(); $allowed = $wp_roles ? array_keys( $wp_roles->roles ) : []; $roles = is_array( $roles ) ? $roles : []; $roles = array_map( 'sanitize_key', $roles ); return array_values( array_intersect( array_unique( $roles ), $allowed ) ); } /** * Get default form field data. * * @return array */ public static function get_default_form_field_data() { return [ 'label' => '', 'name' => '', 'type' => 'text', 'description' => '', 'placeholder' => '', 'default' => '', 'minlength' => '', 'maxlength' => '', 'rows' => '', 'show_counter' => 0, 'html_message' => '', 'hidden_value' => '', 'choices' => '', 'empty_option_label' => '', 'default_choice' => '', 'default_choices' => [], 'multiple' => 0, 'choice_layout' => 'stacked', 'required' => 0, 'number_min' => '', 'number_max' => '', 'number_step' => '', 'date_min' => '', 'date_max' => '', 'allowed_file_types' => '', 'max_file_size_mb' => '2', 'image_min_width' => '', 'image_max_width' => '', 'image_min_height' => '', 'image_max_height' => '', 'store_as_attachment' => 1, 'rating_max' => '5', 'rating_allow_half' => 0, 'hide_label' => 0, 'settings' => [ 'gap' => 'md', 'stack_mobile' => 1, ], 'children' => [], 'conditions' => self::get_default_field_conditions(), ]; } /** * Get the default (inactive) conditional-logic shape for a field or Row. * * @return array{enabled:int,logic:string,rules:array>} */ public static function get_default_field_conditions() { return [ 'enabled' => 0, 'logic' => 'all', 'rules' => [], ]; } /** * Get default form settings. * * @return array */ public static function get_default_form_settings() { $defaults = [ 'submit_label' => __( 'Submit', 'admin-optimizer' ), 'submit_css_class' => '', 'success_message' => __( 'Thanks, your submission has been received.', 'admin-optimizer' ), 'success_behavior' => 'message', 'delivery_mode' => 'email_and_store', 'mail_to' => get_option( 'admin_email', '' ), 'mail_from_name' => get_bloginfo( 'name' ), 'mail_from_email' => get_option( 'admin_email', '' ), 'mail_reply_to' => '', 'mail_smtp_profile' => '', 'mail_subject' => __( 'New form submission', 'admin-optimizer' ), 'mail_message' => __( 'A new form entry has been submitted.', 'admin-optimizer' ), 'mail_send_as_html' => 0, 'enable_autoresponder' => 0, 'autoresponder_to_field' => '', 'autoresponder_smtp_profile' => '', 'autoresponder_subject' => __( 'Thanks for contacting us', 'admin-optimizer' ), 'autoresponder_message' => __( 'We received your submission and will get back to you soon.', 'admin-optimizer' ), 'autoresponder_send_as_html' => 0, 'disable_frontend_css' => 0, 'enable_honeypot' => 1, 'redirect_url' => '', 'submission_limit_enabled' => 0, 'submission_limit_max_entries' => '', 'submission_close_at' => '', 'submission_closed_message' => __( 'This form is now closed.', 'admin-optimizer' ), 'access_mode' => 'public', 'access_roles' => [], 'access_denied_message' => __( 'You do not have permission to view this form.', 'admin-optimizer' ), 'test_mode' => 0, 'style_enabled' => 1, 'style_primary_color' => '', 'style_text_color' => '', 'style_bg_color' => '', 'style_border_color' => '', 'style_border_radius' => '', 'style_msg_success_bg' => '', 'style_msg_success_text' => '', 'style_msg_success_border' => '', 'style_msg_error_bg' => '', 'style_msg_error_text' => '', 'style_msg_error_border' => '', 'style_msg_border_radius' => '', 'style_msg_padding' => '', 'style_btn_bg' => '', 'style_btn_text' => '', 'style_btn_border' => '', 'style_btn_radius' => '', 'canvas_submit_button' => 0, 'integrations' => [], ]; /** * Lets an extension (Pro) add its own default settings keys (e.g. * Custom CSS's `custom_css`/`custom_css_enabled`), so a never-saved * form and wp_parse_args() fallbacks both see them without Free * needing to know those keys' names. * * @param array $defaults Default settings. */ return apply_filters( 'adminoptim_form_builder_default_settings', $defaults ); } /** * Get sanitized form field definitions. * * @param int $form_id Form ID. * * @return array> */ public static function get_form_fields( $form_id ) { $raw_settings = get_post_meta( $form_id, Form_Builder::META_SETTINGS, true ); for ( $i = 0; $i < 3 && is_string( $raw_settings ) && is_serialized( $raw_settings ); $i++ ) { $raw_settings = maybe_unserialize( $raw_settings ); } $uses_canvas_submit = is_array( $raw_settings ) && ! empty( $raw_settings['canvas_submit_button'] ); $legacy_label = is_array( $raw_settings ) && ! empty( $raw_settings['submit_label'] ) ? sanitize_text_field( (string) $raw_settings['submit_label'] ) : self::get_default_form_settings()['submit_label']; $fields = get_post_meta( $form_id, Form_Builder::META_FIELDS, true ); $fields = is_array( $fields ) ? array_values( $fields ) : []; $fields = self::sanitize_stored_form_fields_tree( $fields ); if ( ! $uses_canvas_submit && ! self::find_field_type( $fields, 'submit' ) ) { $fields[] = self::make_submit_field( $legacy_label ); } return self::finalize_conditions_tree( $fields ); } /** * Get the fields whose initial value may be overridden per embed, keyed by * field name. Hidden fields qualify by default; every other supported type * only when its `allow_prepopulation` setting is on. * * @param array> $fields Sanitized field tree. * * @return array> */ public static function get_prepopulation_fields( array $fields ) { $eligible = []; foreach ( Form_Builder_Entries::flatten_submission_fields( $fields ) as $field ) { $name = (string) ( $field['name'] ?? '' ); if ( '' === $name || empty( $field['allow_prepopulation'] ) || ! Fields::supports_prepopulation( (string) ( $field['type'] ?? '' ) ) ) { continue; } $eligible[ $name ] = $field; } return $eligible; } /** * Resolve static initial-value overrides (from a shortcode or a Form block) * against a form's fields. The single place both embeds share for field * matching, eligibility, validation and normalization; it reads the already * loaded field tree and never writes to it or to post meta. * * Overrides are literal values. A key counts as an override whenever it is * present, so an explicit empty string/array is returned (and intentionally * blanks the field) rather than being treated as "no override". A value that * is ineligible, unknown or fails the field's normal sanitization and * constraints is dropped, so the field keeps its configured default. * * @param array> $fields Sanitized field tree. * @param array $overrides Field name => string, or array of strings for multi-value fields. * @param int $form_id Form post ID. * * @return array> Field name => normalized initial value. */ public static function resolve_prepopulation( array $fields, array $overrides, $form_id = 0 ) { /** * Filters the requested static overrides before they are matched and * validated. Free passes them through untouched, as literal strings; * this is the seam a later release can use to turn other kinds of * values into plain values. Whatever it returns still goes through the * same eligibility, sanitization and constraint checks below. * * @param array $overrides Field name => requested value. * @param array> $fields Sanitized field tree. * @param int $form_id Form post ID. */ $overrides = apply_filters( 'adminoptim_form_builder_prepopulation_overrides', $overrides, $fields, absint( $form_id ) ); $resolved = []; if ( ! is_array( $overrides ) || empty( $overrides ) ) { return $resolved; } $eligible = self::get_prepopulation_fields( $fields ); foreach ( $overrides as $key => $requested ) { $name = (string) $key; if ( ! isset( $eligible[ $name ] ) ) { continue; } $field = $eligible[ $name ]; $choices = Fields::parse_choices( $field['choices'] ?? '' ); $multi = 'check' === $field['type'] || ( 'select' === $field['type'] && ! empty( $field['multiple'] ) ); // --- 1. Shape the requested value --- if ( is_array( $requested ) ) { if ( ! $multi ) { continue; } $items = []; foreach ( $requested as $item ) { if ( ! is_scalar( $item ) ) { continue 2; } $items[] = (string) $item; } } elseif ( is_scalar( $requested ) ) { $requested = (string) $requested; if ( $multi ) { // A shortcode can only carry a string: a single choice, or a comma-separated list. $items = isset( $choices[ $requested ] ) ? [ $requested ] : array_map( 'trim', explode( ',', $requested ) ); } } else { continue; } // --- 2. Explicit empty value --- if ( $multi ) { $items = array_values( array_filter( $items, 'strlen' ) ); if ( empty( $items ) ) { $resolved[ $name ] = []; continue; } $requested = $items; } elseif ( '' === $requested ) { $resolved[ $name ] = ''; continue; } // --- 3. Same sanitization and constraints as a submission --- $sanitized = Fields::sanitize_runtime_value( $field, $requested ); if ( Fields::is_effectively_empty_value( $field, $sanitized ) ) { continue; } if ( $multi && count( $sanitized ) !== count( $requested ) ) { continue; } if ( '' !== Form_Builder_Entries::validate_field_constraints( $field, $sanitized, $requested ) ) { continue; } $resolved[ $name ] = $sanitized; } return $resolved; } /** * Sanitize stored nested field definitions without mutating post meta. * * @param array> $fields Raw stored fields. * @param bool $nested Whether these fields are inside a container. * * @return array> */ private static function sanitize_stored_form_fields_tree( array $fields, $nested = false ) { $clean_fields = []; $submit_seen = false; foreach ( $fields as $field ) { if ( ! is_array( $field ) ) { continue; } $legacy_columns = ( 'row' === (string) ( $field['type'] ?? '' ) && isset( $field['settings']['columns'] ) ) ? (string) $field['settings']['columns'] : ''; $clean_field = Fields::sanitize_definition( $field ); if ( 'turnstile' === ( $clean_field['type'] ?? '' ) ) { $clean_field['display_mode'] = 'invisible' === ( $field['display_mode'] ?? '' ) ? 'invisible' : 'managed'; } // Star Rating is Pro-only; strip fields saved while Pro was active so // they stop appearing in the builder/frontend as soon as Pro is off, // mirroring the same gate applied on save in sanitize_form_fields_tree(). if ( 'rating' === (string) $clean_field['type'] && ! Module_Registry::is_pro_active() ) { continue; } $clean_field['conditions'] = self::sanitize_raw_conditions( $field['conditions'] ?? [] ); if ( 'row' === (string) $clean_field['type'] ) { if ( $nested ) { continue; } $clean_field['name'] = ''; $clean_field['required'] = 0; $clean_field['children'] = ! empty( $field['children'] ) && is_array( $field['children'] ) ? self::normalize_legacy_row_child_widths( self::sanitize_stored_form_fields_tree( array_values( $field['children'] ), true ), $legacy_columns ) : []; unset( $clean_field['width'] ); $clean_fields[] = $clean_field; continue; } // Hidden inputs carry data but have no visual width, so they are only // meaningful at the top level of a form. if ( $nested && 'hidden' === (string) $clean_field['type'] ) { continue; } if ( 'submit' === (string) $clean_field['type'] ) { if ( $submit_seen ) { continue; } $submit_seen = true; $clean_field['name'] = ''; $clean_field['label'] = '' !== trim( (string) $clean_field['label'] ) ? $clean_field['label'] : self::get_default_form_settings()['submit_label']; $clean_field['required'] = 0; $clean_field['children'] = []; $clean_field['conditions'] = self::sanitize_raw_conditions( [] ); if ( ! $nested ) { unset( $clean_field['width'] ); } $clean_fields[] = $clean_field; continue; } if ( empty( $clean_field['name'] ) && ! in_array( (string) $clean_field['type'], [ 'html_message', 'recaptcha', 'turnstile' ], true ) ) { continue; } if ( in_array( (string) $clean_field['type'], [ 'file', 'image' ], true ) ) { $clean_field['allowed_file_types'] = isset( $field['allowed_file_types'] ) ? sanitize_text_field( (string) $field['allowed_file_types'] ) : ''; $clean_field['max_file_size_mb'] = isset( $field['max_file_size_mb'] ) && '' !== trim( (string) $field['max_file_size_mb'] ) ? (string) max( 0, (float) $field['max_file_size_mb'] ) : ''; $clean_field = array_merge( $clean_field, self::sanitize_image_dimensions( $field ) ); $clean_field['store_as_attachment'] = array_key_exists( 'store_as_attachment', $field ) ? ( ! empty( $field['store_as_attachment'] ) ? 1 : 0 ) : 1; } if ( ! $nested ) { unset( $clean_field['width'] ); } $clean_fields[] = $clean_field; } $clean_fields = array_values( $clean_fields ); if ( ! $nested ) { $submit_seen = false; $clean_fields = self::deduplicate_submit_items( $clean_fields, $submit_seen ); } return $clean_fields; } /** Keep at most one submit item across the full tree, preserving its position. */ private static function deduplicate_submit_items( array $fields, &$submit_seen ) { $clean = []; foreach ( $fields as $field ) { if ( 'submit' === (string) ( $field['type'] ?? '' ) ) { if ( $submit_seen ) { continue; } $submit_seen = true; } if ( 'row' === (string) ( $field['type'] ?? '' ) ) { $field['children'] = self::deduplicate_submit_items( (array) ( $field['children'] ?? [] ), $submit_seen ); } $clean[] = $field; } return array_values( $clean ); } /** * Build the structural submit item used by blank forms and legacy reads. * * @param string $label Button label. * @return array */ public static function make_submit_field( $label = '' ) { $label = '' !== trim( (string) $label ) ? sanitize_text_field( (string) $label ) : self::get_default_form_settings()['submit_label']; return Fields::sanitize_definition( [ 'type' => 'submit', 'label' => $label, 'name' => '', ] ); } /** * Recursively find the first item of a type in a field tree. * * @param array> $fields Field tree. * @param string $type Type key. * @return array|null */ private static function find_field_type( array $fields, $type ) { foreach ( $fields as $field ) { if ( $type === (string) ( $field['type'] ?? '' ) ) { return $field; } if ( 'row' === (string) ( $field['type'] ?? '' ) && ! empty( $field['children'] ) && is_array( $field['children'] ) ) { $found = self::find_field_type( $field['children'], $type ); if ( $found ) { return $found; } } } return null; } /** * Get form settings with defaults. * * @param int $form_id Form ID. * * @return array */ public static function get_form_settings( $form_id ) { $settings = get_post_meta( $form_id, Form_Builder::META_SETTINGS, true ); $defaults = self::get_default_form_settings(); for ( $i = 0; $i < 3 && is_string( $settings ) && is_serialized( $settings ); $i++ ) { $settings = maybe_unserialize( $settings ); } $settings = wp_parse_args( is_array( $settings ) ? $settings : [], $defaults ); $settings['integrations'] = isset( $settings['integrations'] ) && is_array( $settings['integrations'] ) ? $settings['integrations'] : []; if ( ! in_array( $settings['delivery_mode'], [ 'email_and_store', 'email_only', 'store_only' ], true ) ) { $settings['delivery_mode'] = $defaults['delivery_mode']; } foreach ( [ 'submit_label', 'success_message', 'mail_to', 'mail_from_name', 'mail_from_email', 'mail_subject', 'mail_message' ] as $setting_key ) { if ( '' === trim( (string) $settings[ $setting_key ] ) ) { $settings[ $setting_key ] = $defaults[ $setting_key ]; } } $settings['success_behavior'] = isset( $settings['success_behavior'] ) && 'replace' === sanitize_key( (string) $settings['success_behavior'] ) ? 'replace' : 'message'; $disable_frontend_css = is_scalar( $settings['disable_frontend_css'] ) ? $settings['disable_frontend_css'] : 0; $enable_honeypot = is_scalar( $settings['enable_honeypot'] ) ? $settings['enable_honeypot'] : 0; $settings['disable_frontend_css'] = filter_var( $disable_frontend_css, FILTER_VALIDATE_BOOLEAN ) ? 1 : 0; $settings['enable_honeypot'] = filter_var( $enable_honeypot, FILTER_VALIDATE_BOOLEAN ) ? 1 : 0; $limit_enabled = is_scalar( $settings['submission_limit_enabled'] ) ? $settings['submission_limit_enabled'] : 0; $settings['submission_limit_enabled'] = filter_var( $limit_enabled, FILTER_VALIDATE_BOOLEAN ) ? 1 : 0; $settings['submission_limit_max_entries'] = isset( $settings['submission_limit_max_entries'] ) && absint( $settings['submission_limit_max_entries'] ) ? absint( $settings['submission_limit_max_entries'] ) : ''; $settings['submission_close_at'] = is_scalar( $settings['submission_close_at'] ) ? sanitize_text_field( (string) $settings['submission_close_at'] ) : ''; $settings['submission_closed_message'] = '' !== trim( (string) $settings['submission_closed_message'] ) ? sanitize_textarea_field( $settings['submission_closed_message'] ) : $defaults['submission_closed_message']; $settings['access_mode'] = is_scalar( $settings['access_mode'] ) ? sanitize_key( (string) $settings['access_mode'] ) : $defaults['access_mode']; if ( ! in_array( $settings['access_mode'], [ 'public', 'logged_in', 'roles' ], true ) ) { $settings['access_mode'] = $defaults['access_mode']; } $settings['access_roles'] = is_array( $settings['access_roles'] ) ? array_values( array_map( 'sanitize_key', $settings['access_roles'] ) ) : []; if ( 'roles' !== $settings['access_mode'] ) { $settings['access_roles'] = []; } $settings['access_denied_message'] = '' !== trim( (string) $settings['access_denied_message'] ) ? sanitize_textarea_field( $settings['access_denied_message'] ) : $defaults['access_denied_message']; return $settings; } /** * Check if selectable SMTP profiles are available. * * @return bool */ public function has_smtp_profile_choices() { // Derived from the saved profiles themselves: a stored "has_multiple_profiles" flag is no longer written by Email & SMTP. return count( $this->get_smtp_profile_choices() ) > 1; } /** * Get enabled SMTP profile choices. * * @return array */ public function get_smtp_profile_choices() { $options = get_option( 'adminoptim_smtp', [] ); $options = is_array( $options ) ? $options : []; $router_class = '\\Yipresser\\AdminOptimizerPro\\Modules\\SMTP_Router'; $primary_label = is_callable( [ $router_class, 'primary_profile_label' ] ) ? $router_class::primary_profile_label( $options ) : __( 'Primary SMTP Configuration', 'admin-optimizer' ); $choices = [ 'primary' => $primary_label, ]; $profiles = isset( $options['additional_profiles'] ) && is_array( $options['additional_profiles'] ) ? $options['additional_profiles'] : []; foreach ( $profiles as $profile_id => $profile ) { if ( ! is_array( $profile ) || empty( $profile['enabled'] ) ) { continue; } $profile_id = sanitize_key( str_replace( '-', '_', (string) $profile_id ) ); if ( '' === $profile_id ) { continue; } $choices[ $profile_id ] = ! empty( $profile['name'] ) ? sanitize_text_field( (string) $profile['name'] ) : $profile_id; } return $choices; } /** * Get Email-type field choices for the autoresponder recipient setting. * * @param array> $fields Form field definitions. * * @return array */ public function get_email_field_choices( array $fields ) { $choices = []; foreach ( Form_Builder_Entries::flatten_submission_fields( $fields ) as $field ) { $name = isset( $field['name'] ) ? (string) $field['name'] : ''; if ( '' === $name || 'email' !== (string) ( $field['type'] ?? '' ) ) { continue; } $choices[ $name ] = ! empty( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : $name; } return $choices; } /** * Sanitize selected SMTP profile. * * @param mixed $profile_id Raw profile ID. * * @return string */ private function sanitize_smtp_profile_setting( $profile_id ) { $profile_id = sanitize_key( str_replace( '-', '_', (string) $profile_id ) ); if ( '' === $profile_id ) { return ''; } $choices = $this->get_smtp_profile_choices(); return isset( $choices[ $profile_id ] ) ? $profile_id : ''; } /** * Describe which Pro-gated settings are currently stored on a form but * not enforced because Pro is inactive, for the Settings tab's "Saved but * not enforced" notice. Returns human-readable labels, not raw keys, so * the JS can join them directly into a sentence. * * @param int $form_id Form post ID. * * @return array */ public static function get_inert_settings_labels( $form_id ) { if ( Module_Registry::is_pro_active() ) { return []; } $settings = self::get_form_settings( $form_id ); $inert = []; if ( 'public' !== $settings['access_mode'] ) { $inert[] = __( 'access rules', 'admin-optimizer' ); } if ( ! empty( $settings['submission_limit_max_entries'] ) ) { $inert[] = __( 'a submission limit', 'admin-optimizer' ); } if ( ! empty( $settings['submission_close_at'] ) ) { $inert[] = __( 'a close date', 'admin-optimizer' ); } if ( '' !== trim( (string) $settings['redirect_url'] ) ) { $inert[] = __( 'a redirect', 'admin-optimizer' ); } return $inert; } /** * Whether this form has a non-empty `integrations` map saved while Pro is * inactive, for the Integrations tab's "Saved but not running" notice. * * @param int $form_id Form post ID. * * @return bool */ public static function has_inert_integrations( $form_id ) { if ( Module_Registry::is_pro_active() ) { return false; } $settings = self::get_form_settings( $form_id ); return ! empty( $settings['integrations'] ) && is_array( $settings['integrations'] ); } /** * Whether this form has non-empty Custom CSS (Pro) saved while Pro is * inactive, for the Style tab's "Saved but not running" notice. Reads * the `custom_css` key the same way `has_inert_integrations()` reads * `integrations` — a Pro-owned settings key Free never defines itself, * but preserves verbatim (see `build_form_settings()`'s array_merge() * with the old settings) and can safely check for presence/non-emptiness * of generically. * * @param int $form_id Form post ID. * * @return bool */ public static function has_inert_custom_css( $form_id ) { if ( Module_Registry::is_pro_active() ) { return false; } $settings = self::get_form_settings( $form_id ); return isset( $settings['custom_css'] ) && '' !== trim( (string) $settings['custom_css'] ); } /** * Get the list of starter form presets shown as a locked teaser on the * builder page's Presets card when Pro is inactive. Pro renders its own * live preset panel through the `adminoptim_form_builder_render_preset_panel` * action instead of this data. * * @return array */ public function get_form_presets() { return [ [ 'title' => __( 'Contact Form', 'admin-optimizer' ), 'description' => __( 'Collect a simple message with sender details.', 'admin-optimizer' ), 'fields_label' => __( '4 fields', 'admin-optimizer' ), ], [ 'title' => __( 'Quote Request', 'admin-optimizer' ), 'description' => __( 'Gather project scope and budget information.', 'admin-optimizer' ), 'fields_label' => __( '5 fields', 'admin-optimizer' ), ], [ 'title' => __( 'Support Request', 'admin-optimizer' ), 'description' => __( 'Capture issue details and urgency.', 'admin-optimizer' ), 'fields_label' => __( '4 fields', 'admin-optimizer' ), ], [ 'title' => __( 'Newsletter Signup', 'admin-optimizer' ), 'description' => __( 'Collect email subscribers with an optional name.', 'admin-optimizer' ), 'fields_label' => __( '2 fields', 'admin-optimizer' ), ], [ 'title' => __( 'Product Review', 'admin-optimizer' ), 'description' => __( 'Collect structured product feedback and permission to publish it.', 'admin-optimizer' ), 'fields_label' => __( '10 fields', 'admin-optimizer' ), ], ]; } /** * Split a field's allowed_file_types string into lowercase items without * leading dots. Items are extensions or MIME types. * * @param mixed $value Stored allowed_file_types value. * * @return array */ public static function parse_allowed_file_types( $value ) { $items = array_map( static function ( $item ) { return strtolower( trim( ltrim( trim( (string) $item ), '.' ) ) ); }, (array) preg_split( '/\s*,\s*/', (string) $value ) ); return array_values( array_unique( array_filter( $items ) ) ); } /** * The image dimension limits that apply to a file field, in pixels. * * Limits only apply while every allowed type is an image. The builder hides * them otherwise, and a hidden limit must not reject a PDF. * * @param array $field Field definition. * * @return array Set limits, keyed min_width, max_width, min_height, max_height. */ public static function get_image_dimension_limits( array $field ) { $items = self::parse_allowed_file_types( $field['allowed_file_types'] ?? '' ); if ( ! $items ) { return []; } foreach ( $items as $item ) { $mime_type = false !== strpos( $item, '/' ) ? $item : (string) wp_check_filetype( 'file.' . $item )['type']; if ( 0 !== strpos( $mime_type, 'image/' ) ) { return []; } } $limits = []; foreach ( [ 'min_width', 'max_width', 'min_height', 'max_height' ] as $limit ) { $pixels = absint( $field[ 'image_' . $limit ] ?? 0 ); if ( $pixels ) { $limits[ $limit ] = $pixels; } } return $limits; } /** * Sanitize the image dimension settings of a raw file field. * * @param array $raw_field Raw field definition. * * @return array Pixel values, empty when unset. */ private static function sanitize_image_dimensions( array $raw_field ) { $clean = []; foreach ( [ 'image_min_width', 'image_max_width', 'image_min_height', 'image_max_height' ] as $key ) { $pixels = absint( $raw_field[ $key ] ?? 0 ); $clean[ $key ] = $pixels ? (string) $pixels : ''; } return $clean; } /** * Get supported field types for the starter form builder. * * @return array */ public function get_supported_field_types() { $labels = Fields::get_type_labels(); $types = [ 'row', 'submit', 'hidden', 'text', 'email', 'textarea', 'select', 'radio', 'check', 'number', 'phone', 'url', 'file', 'datepicker', 'html_message', 'recaptcha', 'turnstile', ]; $supported = []; foreach ( $types as $type ) { if ( isset( $labels[ $type ] ) ) { $supported[ $type ] = $labels[ $type ]; } } /** * Filters the field types offered by the form builder. Pro hooks this * to add 'rating' (Star Rating) — Free has no such type of its own. * * @param array $supported Type key => label. */ return apply_filters( 'adminoptim_form_builder_field_types', $supported ); } /** * Get supported field capabilities keyed by field type. * * @return array */ public function get_supported_field_capabilities() { $capabilities = []; foreach ( array_keys( $this->get_supported_field_types() ) as $type ) { $capabilities[ $type ] = [ 'supports_choices' => Fields::supports_choices( $type ), 'supports_required' => Fields::supports_required( $type ), 'supports_layout' => in_array( $type, [ 'row', 'submit' ], true ), 'supports_css_class' => Fields::supports_css_class( $type ), 'supports_prepopulation' => Fields::supports_prepopulation( $type ), ]; } /** * Filters the field capability map alongside adminoptim_form_builder_field_types. * * @param array> $capabilities Type key => capability flags. */ return apply_filters( 'adminoptim_form_builder_field_capabilities', $capabilities ); } /** * Enqueue scripts and styles for the form builder settings screen. * * @return void */ public function enqueue_settings_scripts() { $screen = get_current_screen(); if ( ! $screen || 'adminoptim-form' !== $screen->post_type ) { return; } wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_style( 'adminoptimizer-form-builder-settings', Form_Builder::MODULE_URL . 'assets/css/form-builder-settings.min.css', [ 'wp-color-picker' ], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptimizer-form-builder-settings', Form_Builder::MODULE_URL . 'assets/js/form-builder-settings.min.js', [ 'wp-color-picker' ], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptimizer-form-builder-settings', 'aofbData', [ 'fieldTypes' => $this->get_supported_field_types(), 'fieldMeta' => $this->get_supported_field_capabilities(), 'untitledLabel' => __( 'Untitled field', 'admin-optimizer' ), ] ); do_action( 'adminoptim_form_builder_enqueue_settings_scripts' ); } }