$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' );
}
}