Data Names the last save renamed, old => new. */ private $last_server_renames = array(); const MENU_SLUG = 'holmesforms-forms'; const BUILD_SLUG = 'holmesforms-form-edit'; const ENTRIES_SLUG = 'holmesforms-entries'; const SETTINGS_SLUG = 'holmesforms-settings'; const ADDONS_SLUG = 'holmesforms-add-ons'; const SETTINGS = 'holmesforms_settings'; const THEME_META = 'holmesforms_admin_dark'; const REVISION_META = '_holmesforms_revisions'; const REVISION_MAX = 30; const MENU_POSITION = 26; const MAX_LISTED_FORMS = 200; const MAX_IMPORT_FORMS = 250; const MAX_COPY_NUMBER = 100; const MAX_RENAMES_PER_SAVE = 100; const MAX_ENTRY_LABEL_KEYS = 2; const MAX_AFFIX_LENGTH = 12; const MAX_REVISION_NOTE_LENGTH = 300; const MAX_REF_PREFIX_LENGTH = 12; const MAX_MAIL_CAP = 100000; const MAX_FLOOD_LIMIT = 100000; const MAX_OWN_WORDS_LENGTH = 20000; const MAX_WORDS_MESSAGE_LENGTH = 300; const ENCRYPTION_KEY_BYTES = 32; const ENCRYPT_BATCHES = 20; const ENCRYPT_BATCH_SIZE = 200; const IMPORT_TOKEN_LENGTH = 20; const IMPORT_PENDING_SECONDS = 10 * MINUTE_IN_SECONDS; const SEALED_CHECK_CACHE_SECONDS = 5 * MINUTE_IN_SECONDS; const ENCRYPTION_CHECK_CACHE_SECONDS = 10 * MINUTE_IN_SECONDS; const STORAGE_USAGE_CACHE_SECONDS = 5 * MINUTE_IN_SECONDS; const UNREAD_COUNT_CACHE_SECONDS = MINUTE_IN_SECONDS; const TEST_MAIL_NOTICE_SECONDS = 5 * MINUTE_IN_SECONDS; const SNOOZE_SHORT_SECONDS = 30 * DAY_IN_SECONDS; const SNOOZE_LONG_SECONDS = 90 * DAY_IN_SECONDS; const BANK_HOLIDAY_WARNING_SECONDS = 180 * DAY_IN_SECONDS; const CAPTCHA_TEST_TIMEOUT_SECONDS = 8; const HUB_SECTION_STYLES = 5; private static $instance = null; public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-components.php'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-form.php'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-logic.php'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-a11y.php'; add_filter( 'user_has_cap', array( $this, 'grant_holmesforms_caps_to_admins' ) ); add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'redirect_core_screens' ), 1 ); add_filter( 'option_page_capability_holmesforms_settings_group', array( $this, 'settings_capability' ) ); add_action( 'admin_init', array( $this, 'handle_reminder_action' ) ); add_action( 'admin_init', array( $this, 'handle_entries_request' ) ); add_action( 'admin_notices', array( $this, 'encryption_notice' ) ); add_action( 'admin_notices', array( $this, 'second_step_notice' ) ); add_action( 'holmesforms_entries_deleted', array( $this, 'clear_sealed_rows_cache' ) ); add_action( 'admin_notices', array( $this, 'welcome_notice' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_post_holmesforms_theme_toggle', array( $this, 'handle_theme_toggle' ) ); add_action( 'admin_post_holmesforms_test_email', array( $this, 'handle_test_email' ) ); add_filter( 'admin_body_class', array( $this, 'add_theme_body_class' ) ); add_filter( 'plugin_action_links_' . plugin_basename( HOLMESFORMS_FILE ), array( $this, 'plugin_action_links' ) ); add_filter( 'admin_title', array( $this, 'builder_admin_title' ), 10, 2 ); add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) ); add_action( 'admin_post_holmesforms_save_form', array( $this, 'handle_save_form' ) ); add_action( 'admin_post_holmesforms_delete_form', array( $this, 'handle_delete_form' ) ); add_action( 'admin_post_holmesforms_restore_revision', array( $this, 'handle_restore_revision' ) ); add_action( 'admin_post_holmesforms_take_lock', array( $this, 'handle_take_lock' ) ); add_action( 'admin_post_holmesforms_encrypt_backfill', array( $this, 'handle_encrypt_backfill' ) ); add_action( 'admin_post_holmesforms_adopt_key', array( $this, 'handle_adopt_key' ) ); add_action( 'admin_post_holmesforms_unsaved_seen', array( $this, 'handle_unsaved_seen' ) ); add_action( 'wp_ajax_holmesforms_revisions', array( $this, 'ajax_revisions' ) ); add_action( 'wp_ajax_holmesforms_revision_diff', array( $this, 'ajax_revision_diff' ) ); add_action( 'wp_ajax_holmesforms_delete_revision', array( $this, 'ajax_delete_revision' ) ); add_action( 'wp_ajax_holmesforms_lock_check', array( $this, 'ajax_lock_check' ) ); add_action( 'wp_ajax_holmesforms_test_captcha', array( $this, 'ajax_test_captcha' ) ); add_action( 'wp_ajax_holmesforms_release_lock', array( $this, 'ajax_release_lock' ) ); add_action( 'wp_ajax_holmesforms_tip_done', array( $this, 'ajax_tip_done' ) ); add_action( 'admin_post_holmesforms_export_entries', array( $this, 'handle_export' ) ); add_action( 'admin_post_holmesforms_import_config', array( $this, 'handle_import_config' ) ); add_action( 'admin_post_holmesforms_export_form', array( $this, 'handle_export_form' ) ); add_action( 'admin_post_holmesforms_duplicate_form', array( $this, 'handle_duplicate_form' ) ); add_action( 'admin_post_holmesforms_add_to_page', array( $this, 'handle_add_to_page' ) ); add_action( 'admin_post_holmesforms_import_form', array( $this, 'handle_import_form' ) ); add_action( 'admin_post_holmesforms_import_confirm', array( $this, 'handle_import_confirm' ) ); add_action( 'admin_post_holmesforms_restore_forms', array( $this, 'handle_restore_forms' ) ); add_action( 'admin_post_holmesforms_restore_blocks', array( $this, 'handle_restore_blocks' ) ); add_action( 'admin_post_holmesforms_lead_opt_in', array( $this, 'handle_lead_opt_in' ) ); add_action( 'admin_post_holmesforms_lead_manage', array( $this, 'handle_lead_manage' ) ); add_action( 'wp_ajax_holmesforms_presets', array( $this, 'ajax_presets' ) ); add_action( 'wp_ajax_holmesforms_a11y_review', array( $this, 'ajax_a11y_review' ) ); add_action( 'wp_ajax_holmesforms_formula_preview', array( $this, 'ajax_formula_preview' ) ); } public function ajax_a11y_review() { if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array( 'message' => __( 'Not allowed.', 'holmesportfolio-forms' ) ), 403 ); } check_ajax_referer( 'holmesforms_a11y', 'nonce' ); $definition_json = isset( $_POST['definition'] ) ? sanitize_textarea_field( wp_unslash( $_POST['definition'] ) ) : ''; $definition = json_decode( $definition_json, true ); if ( ! is_array( $definition ) ) { wp_send_json_error( array( 'message' => __( 'Could not read the form.', 'holmesportfolio-forms' ) ) ); } $findings = HP_Forms_A11y::review( $definition ); wp_send_json_success( array( 'findings' => $findings, 'summary' => HP_Forms_A11y::count_by_severity( $findings ), ) ); } public function ajax_formula_preview() { if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array( 'message' => __( 'Not allowed.', 'holmesportfolio-forms' ) ), 403 ); } check_ajax_referer( 'holmesforms_formula', 'nonce' ); require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-logic.php'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-form.php'; $formula = isset( $_POST['formula'] ) ? sanitize_text_field( wp_unslash( $_POST['formula'] ) ) : ''; $display_options = array(); foreach ( array( 'round', 'before', 'after' ) as $key ) { $display_options[ $key ] = isset( $_POST[ $key ] ) ? mb_substr( sanitize_text_field( wp_unslash( $_POST[ $key ] ) ), 0, self::MAX_AFFIX_LENGTH ) : ''; } $values_json = isset( $_POST['values'] ) ? sanitize_textarea_field( wp_unslash( $_POST['values'] ) ) : '{}'; $values = json_decode( $values_json, true ); $values = is_array( $values ) ? map_deep( $values, 'sanitize_text_field' ) : array(); $fields = array(); foreach ( $values as $key => $value ) { $fields[ sanitize_key( (string) $key ) ] = is_scalar( $value ) ? (string) $value : ''; } $previous_region = HP_Forms_Holidays::use_region( HP_Forms_Form::clean_bank_holiday_region( isset( $_POST['bh_region'] ) ? sanitize_key( wp_unslash( $_POST['bh_region'] ) ) : '' ) ); try { $result = HP_Forms_Logic::evaluate_formula( $formula, array( 'fields' => $fields, 'variables' => $fields, ) ); $result = HP_Forms_Components::format_number( $display_options, HP_Forms_Logic::round_value( $display_options['round'], $result ) ); } catch ( \Throwable $e ) { HP_Forms_Holidays::use_region( $previous_region ); wp_send_json_error( array( 'message' => __( 'The formula could not be worked out with those values.', 'holmesportfolio-forms' ) ) ); } HP_Forms_Holidays::use_region( $previous_region ); wp_send_json_success( array( 'result' => (string) $result ) ); } /* Menu */ public function grant_holmesforms_caps_to_admins( $allcaps ) { if ( ! empty( $allcaps['manage_options'] ) ) { $allcaps['holmesforms_admin'] = true; $allcaps[ self::CAP ] = true; $allcaps[ self::CAP_ENTRIES ] = true; $allcaps[ self::CAP_EXPORT ] = true; } return $allcaps; } /** @return int[]|null The forms whose entries this user may see, or null for every form. */ public static function scoped_form_ids() { $ids = apply_filters( 'holmesforms_scoped_form_ids', null ); if ( null === $ids ) { return null; } return array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) ); } /** @return int[]|null The forms this user may list and edit, or null for every form. */ public static function editable_form_ids() { $ids = apply_filters( 'holmesforms_editable_form_ids', null ); if ( null === $ids ) { return null; } return array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) ); } public static function can_see_form_entries( $form_id ) { $scope = self::scoped_form_ids(); return null === $scope || in_array( (int) $form_id, $scope, true ); } public static function can_see_entry( $entry ) { $can_see = self::can_see_form_entries( (int) ( is_array( $entry ) ? ( $entry['form_id'] ?? 0 ) : 0 ) ); return (bool) apply_filters( 'holmesforms_can_see_entry', $can_see, $entry ); } public function settings_capability() { return 'holmesforms_admin'; } public static function is_forms_admin() { return current_user_can( 'holmesforms_admin' ); } private function has_sealed_rows() { $cached = get_transient( 'holmesforms_enc_has_sealed' ); if ( false !== $cached ) { return (bool) $cached; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- the plugin's own table, a fixed search for sealed rows $found = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT EXISTS( SELECT 1 FROM %i WHERE data LIKE %s )', HP_Forms_Storage::table(), $wpdb->esc_like( 'hpfenc1:' ) . '%' ) ); set_transient( 'holmesforms_enc_has_sealed', $found ? 1 : 0, self::SEALED_CHECK_CACHE_SECONDS ); return (bool) $found; } public function clear_sealed_rows_cache() { delete_transient( 'holmesforms_enc_has_sealed' ); } public function plugin_action_links( $links ) { $our_links = sprintf( '%s | %s', esc_url( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ), esc_html__( 'Forms', 'holmesportfolio-forms' ), esc_url( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG ) ), esc_html__( 'Settings', 'holmesportfolio-forms' ) ); return array_merge( array( $our_links ), (array) $links ); } public function admin_footer_text( $text ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, picks the screen $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( 0 !== strpos( $page, 'holmesforms' ) ) { return $text; } $label = esc_html__( 'HolmesPortfolio Forms', 'holmesportfolio-forms' ); if ( defined( 'HOLMESFORMS_VERSION' ) && HOLMESFORMS_VERSION ) { /* translators: %s: version number */ $label .= ' ' . sprintf( esc_html__( 'version %s', 'holmesportfolio-forms' ), esc_html( HOLMESFORMS_VERSION ) ); } return $label . ' · ' . esc_html__( 'Get help', 'holmesportfolio-forms' ) . self::new_tab_hint() . ''; } public function welcome_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a flag from the activation redirect if ( empty( $_GET['holmesforms_welcome'] ) || ! current_user_can( self::CAP ) ) { return; } $screen = get_current_screen(); if ( ! $screen || false === strpos( $screen->id, self::MENU_SLUG ) ) { return; } echo '

' . esc_html__( 'HolmesPortfolio Forms is ready.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'The example forms below are yours to open, copy, or drop on a page with their shortcode.', 'holmesportfolio-forms' ) . '

' . esc_html__( 'Take the form tutorial', 'holmesportfolio-forms' ) . '  ·  ' . '' . esc_html__( 'Read the guide', 'holmesportfolio-forms' ) . ' ' . esc_html__( '(opens in new tab)', 'holmesportfolio-forms' ) . '

'; } public static function enqueue_screen_scripts() { if ( wp_script_is( 'holmesforms-admin-screens', 'enqueued' ) ) { return; } wp_enqueue_script( 'holmesforms-confirm', HOLMESFORMS_URL . 'assets/js/holmesforms-confirm.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-confirm.js' ), true ); wp_localize_script( 'holmesforms-confirm', 'holmesformsConfirmText', array( 'areYouSure' => __( 'Are you sure?', 'holmesportfolio-forms' ), 'cancel' => __( 'Cancel', 'holmesportfolio-forms' ), 'yes' => __( 'Yes', 'holmesportfolio-forms' ), ) ); wp_enqueue_script( 'holmesforms-admin-screens', HOLMESFORMS_URL . 'assets/js/holmesforms-admin-screens.js', array( 'holmesforms-confirm' ), holmesforms_asset_version( 'assets/js/holmesforms-admin-screens.js' ), true ); } public function encryption_notice() { if ( ! current_user_can( self::CAP ) ) { return; } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; global $wpdb; $has_sealed = $this->has_sealed_rows(); $count = 0; $problem = ''; if ( $has_sealed ) { if ( ! HP_Forms_Crypt::is_active() ) { $problem = __( 'The encryption key is no longer available (wp-config.php, an environment variable, or the holmesforms_encryption_key filter).', 'holmesportfolio-forms' ); } elseif ( ! HP_Forms_Crypt::is_key_correct() ) { $problem = __( 'The current encryption key does not match the key that encrypted your entries.', 'holmesportfolio-forms' ); } } if ( '' !== $problem ) { $sealed_check = get_transient( 'holmesforms_enc_check' ); if ( ! is_array( $sealed_check ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- the plugin's own table, a fixed search for sealed rows $sealed_check = array( 'count' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE data LIKE %s', HP_Forms_Storage::table(), $wpdb->esc_like( 'hpfenc1:' ) . '%' ) ) ); set_transient( 'holmesforms_enc_check', $sealed_check, self::ENCRYPTION_CHECK_CACHE_SECONDS ); } $count = (int) $sealed_check['count']; if ( $count < 1 ) { delete_transient( 'holmesforms_enc_has_sealed' ); $problem = ''; } } if ( $has_sealed ) { if ( '' !== $problem ) { echo '

' . esc_html__( 'HolmesPortfolio Forms: stored entries cannot be read.', 'holmesportfolio-forms' ) . ' ' . esc_html( $problem ) . ' ' . esc_html( sprintf( /* translators: %s: number of entries */ _n( '%s stored entry is encrypted and will stay unreadable until the correct key is restored.', '%s stored entries are encrypted and will stay unreadable until the correct key is restored.', $count, 'holmesportfolio-forms' ), number_format_i18n( $count ) ) ) . ' ' . esc_html__( 'Until then, don\'t export entries or use the button that encrypts older entries.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'While the key is wrong, new entries aren\'t saved. Form emails still send.', 'holmesportfolio-forms' ) . '

'; if ( HP_Forms_Crypt::is_active() && current_user_can( 'holmesforms_admin' ) ) { $adopt_url = wp_nonce_url( add_query_arg( 'action', 'holmesforms_adopt_key', admin_url( 'admin-post.php' ) ), 'holmesforms_adopt_key' ); self::enqueue_screen_scripts(); echo '

' . esc_html__( 'If you changed the key on purpose and no longer have the old one, you can use the new key from now on. Entries saved with the old key will stay unreadable.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'Use the new key from now on', 'holmesportfolio-forms' ) . '

'; } echo '
'; return; } } $unsaved_count = (int) get_option( HP_Forms_Storage::OPTION_UNSAVED, 0 ); if ( $unsaved_count > 0 && HP_Forms_Crypt::is_key_correct() ) { $dismiss_url = wp_nonce_url( add_query_arg( 'action', 'holmesforms_unsaved_seen', admin_url( 'admin-post.php' ) ), 'holmesforms_unsaved_seen' ); echo '

' . esc_html( sprintf( /* translators: %s: number of form submissions */ _n( 'While the encryption key was wrong, %s form submission wasn\'t saved as an entry. Its emails were sent as usual.', 'While the encryption key was wrong, %s form submissions weren\'t saved as entries. Their emails were sent as usual.', $unsaved_count, 'holmesportfolio-forms' ), number_format_i18n( $unsaved_count ) ) ) . ' ' . esc_html__( 'Dismiss', 'holmesportfolio-forms' ) . '

'; } if ( ! self::is_reminder_screen() || ! ( current_user_can( 'manage_options' ) || current_user_can( 'holmesforms_admin' ) ) ) { return; } if ( HP_Forms_Crypt::is_active() ) { if ( get_option( 'holmesforms_key_spare_saved' ) || self::is_snoozed( 'spare' ) ) { return; } echo wp_kses( '

' . esc_html__( 'HolmesPortfolio Forms: encryption is on. Do you have a spare copy of the key?', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'The key is the HOLMESFORMS_ENCRYPTION_KEY line in wp-config.php, unless your site sets it somewhere else. This plugin never shows it.', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'Keep a spare copy somewhere other than this website, such as your password manager. If the key is lost, entries saved with it can\'t be read by anyone.', 'holmesportfolio-forms' ) . '

' . '

' . self::reminder_button_html( 'spare', 'saved', __( 'I\'ve saved a spare copy', 'holmesportfolio-forms' ), true ) . ' ' . self::reminder_button_html( 'spare', 'later30', __( 'Remind me in 30 days', 'holmesportfolio-forms' ) ) . '

', HP_Forms_Kses::admin() ); return; } if ( self::is_snoozed( 'enc' ) ) { return; } $settings_url = add_query_arg( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'data', ), admin_url( 'admin.php' ) ); $is_storing = 'never' !== $this->get_setting( 'store_mode' ); /** @param string $title The notice title; an add-on names the other data it stores. */ $title = (string) apply_filters( 'holmesforms_encryption_notice_title', __( 'HolmesPortfolio Forms: stored answers are not encrypted.', 'holmesportfolio-forms' ) ); echo wp_kses( '

' . esc_html( $title ) . ' ' . esc_html__( 'Encryption keeps a copied database or backup unreadable without the key.', 'holmesportfolio-forms' ) . '

' . self::encryption_key_box_html() . '

' . esc_html__( 'Open Data & privacy settings', 'holmesportfolio-forms' ) . ' ' . self::reminder_button_html( 'enc', 'later90', __( 'Remind me in 90 days', 'holmesportfolio-forms' ) ) . '

', HP_Forms_Kses::admin() ); } public function second_step_notice() { if ( ! current_user_can( self::CAP_ENTRIES ) || ! self::is_reminder_screen() || self::is_snoozed( 'twofa' ) ) { return; } $user = wp_get_current_user(); $has_second_factor = apply_filters( 'holmesforms_has_second_factor', null, $user ); $has_two_factor_plugin = class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'is_user_using_two_factor' ); if ( null === $has_second_factor && $has_two_factor_plugin ) { $has_second_factor = (bool) Two_Factor_Core::is_user_using_two_factor( $user->ID ); } if ( true === $has_second_factor ) { return; } if ( $has_two_factor_plugin ) { $text = __( 'You haven\'t set up a second sign-in step yet. Anyone who can see entries should use one as well as their password. It can be a code from an app on your phone.', 'holmesportfolio-forms' ); $link = '' . esc_html__( 'Set it up on your profile', 'holmesportfolio-forms' ) . ''; } elseif ( current_user_can( 'install_plugins' ) ) { $text = __( 'Staff who can see entries should use a second sign-in step as well as their password. It can be a code from an app on their phone. The official Two-Factor plugin adds it for free.', 'holmesportfolio-forms' ); $link = '' . esc_html__( 'Find the Two-Factor plugin', 'holmesportfolio-forms' ) . ''; } else { return; } echo wp_kses( '

' . esc_html__( 'HolmesPortfolio Forms: add a second sign-in step.', 'holmesportfolio-forms' ) . ' ' . esc_html( $text ) . '

' . '

' . $link . ' ' . self::reminder_button_html( 'twofa', 'later90', __( 'Remind me in 90 days', 'holmesportfolio-forms' ) ) . ' ' . self::reminder_button_html( 'twofa', 'never', __( 'I use a different second-step plugin', 'holmesportfolio-forms' ) ) . '

', HP_Forms_Kses::admin() ); } private static function is_reminder_screen() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, picks the screen $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return 0 === strpos( $page, 'holmesforms' ) && self::BUILD_SLUG !== $page; } private static function is_snoozed( $name ) { return (int) get_user_meta( get_current_user_id(), 'holmesforms_snooze_' . $name, true ) > time(); } private static function reminder_button_html( $name, $action, $label, $is_primary = false ) { $url = wp_nonce_url( add_query_arg( array( 'holmesforms_reminder' => $name, 'holmesforms_do' => $action, ) ), 'holmesforms_reminder_' . $name ); return '' . esc_html( $label ) . ''; } public function handle_reminder_action() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, names the reminder; the nonce is checked just below $name = isset( $_GET['holmesforms_reminder'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_reminder'] ) ) : ''; if ( ! in_array( $name, array( 'enc', 'spare', 'twofa' ), true ) ) { return; } check_admin_referer( 'holmesforms_reminder_' . $name ); $action = isset( $_GET['holmesforms_do'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_do'] ) ) : ''; $user_id = get_current_user_id(); if ( 'saved' === $action && 'spare' === $name && current_user_can( self::CAP ) ) { update_option( 'holmesforms_key_spare_saved', time(), false ); } elseif ( 'never' === $action && 'twofa' === $name ) { update_user_meta( $user_id, 'holmesforms_snooze_' . $name, PHP_INT_MAX ); } else { update_user_meta( $user_id, 'holmesforms_snooze_' . $name, time() + ( 'later90' === $action ? self::SNOOZE_LONG_SECONDS : self::SNOOZE_SHORT_SECONDS ) ); } wp_safe_redirect( remove_query_arg( array( 'holmesforms_reminder', 'holmesforms_do', '_wpnonce' ) ) ); exit; } private static function new_tab_hint() { return ' ' . esc_html__( '(opens in new tab)', 'holmesportfolio-forms' ) . ''; } private static function suggested_encryption_key() { static $key = null; if ( null === $key ) { $key = bin2hex( random_bytes( self::ENCRYPTION_KEY_BYTES ) ); } return $key; } /** @return string A fresh key line with Copy and Download buttons, never stored. */ public static function encryption_key_box_html() { self::enqueue_key_script(); $key_line = "define( 'HOLMESFORMS_ENCRYPTION_KEY', '" . self::suggested_encryption_key() . "' );"; return '
    ' . '
  1. ' . esc_html__( 'Copy or download this line. A new key is made each time this page opens, so use the one you saved.', 'holmesportfolio-forms' ) . '
  2. ' . '
  3. ' . esc_html__( 'Paste it into wp-config.php (not .htaccess), above the line that says "That\'s all, stop editing!".', 'holmesportfolio-forms' ) . '
  4. ' . '
  5. ' . esc_html__( 'Keep a spare copy somewhere other than this website, such as your password manager. If the key is lost, entries saved with it can\'t be read by anyone.', 'holmesportfolio-forms' ) . '
  6. ' . '
' . '

' . esc_html( $key_line ) . '

' . '

' . '' . '

'; } private static function enqueue_key_script() { if ( wp_script_is( 'holmesforms-key', 'enqueued' ) ) { return; } wp_enqueue_style( 'holmesforms-admin', HOLMESFORMS_URL . 'assets/css/admin.css', array(), holmesforms_asset_version( 'assets/css/admin.css' ) ); wp_enqueue_script( 'holmesforms-key', HOLMESFORMS_URL . 'assets/js/holmesforms-key.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-key.js' ), true ); wp_localize_script( 'holmesforms-key', 'holmesformsKey', array( 'copied' => __( 'Copied', 'holmesportfolio-forms' ), 'failed' => __( 'Press Ctrl+C to copy', 'holmesportfolio-forms' ), 'downloaded' => __( 'Downloaded', 'holmesportfolio-forms' ), 'fileName' => 'holmesforms-encryption-key-' . sanitize_file_name( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) . '.txt', /* translators: 1: site address, 2: date */ 'fileHead' => sprintf( __( 'HolmesPortfolio Forms encryption key for %1$s, made %2$s.', 'holmesportfolio-forms' ), home_url(), wp_date( 'j F Y' ) ), 'fileFoot' => array( __( 'Paste the line above into wp-config.php, above the line that says "That\'s all, stop editing!".', 'holmesportfolio-forms' ), __( 'Keep this file somewhere other than your website, such as your password manager, then delete it from your Downloads folder.', 'holmesportfolio-forms' ), __( 'If the key is lost, entries saved with it can\'t be read by anyone.', 'holmesportfolio-forms' ), ), ) ); } public function register_menu() { $parent_cap = current_user_can( self::CAP ) ? self::CAP : ( current_user_can( self::CAP_ENTRIES ) ? self::CAP_ENTRIES : self::CAP ); $parent_cap = (string) apply_filters( 'holmesforms_menu_parent_cap', $parent_cap ); $menu_title = (string) apply_filters( 'holmesforms_menu_title', __( 'Forms', 'holmesportfolio-forms' ) ); add_menu_page( __( 'HolmesPortfolio Forms', 'holmesportfolio-forms' ), wp_kses_post( $menu_title ), $parent_cap, self::MENU_SLUG, array( $this, 'screen_list' ), 'dashicons-feedback', self::MENU_POSITION ); add_submenu_page( self::MENU_SLUG, __( 'All Forms', 'holmesportfolio-forms' ), __( 'All Forms', 'holmesportfolio-forms' ), self::CAP, self::MENU_SLUG, array( $this, 'screen_list' ) ); add_submenu_page( self::MENU_SLUG, __( 'Create New form', 'holmesportfolio-forms' ), __( 'Add New', 'holmesportfolio-forms' ), self::CAP, self::BUILD_SLUG, array( $this, 'screen_builder' ) ); add_submenu_page( self::MENU_SLUG, __( 'Entries', 'holmesportfolio-forms' ), __( 'Entries', 'holmesportfolio-forms' ), self::CAP_ENTRIES, self::ENTRIES_SLUG, array( $this, 'screen_entries' ) ); $settings_cap = apply_filters( 'holmesforms_settings_menu_cap', self::CAP ); add_submenu_page( self::MENU_SLUG, __( 'Settings', 'holmesportfolio-forms' ), __( 'Settings', 'holmesportfolio-forms' ), $settings_cap, self::SETTINGS_SLUG, array( $this, 'screen_settings' ) ); add_submenu_page( self::MENU_SLUG, __( 'Pro and Casework', 'holmesportfolio-forms' ), __( 'Pro and Casework', 'holmesportfolio-forms' ), self::CAP, self::ADDONS_SLUG, array( $this, 'screen_add_ons' ) ); if ( ! current_user_can( self::CAP ) ) { remove_submenu_page( self::MENU_SLUG, self::MENU_SLUG ); } } /** The one page about the paid add-on: what Pro and Casework add, and where to read more. */ public function screen_add_ons() { if ( ! current_user_can( self::CAP ) ) { return; } $pro_items = array( array( __( 'Payments', 'holmesportfolio-forms' ), __( 'Payments on a form, through a Stripe or PayPal payment link or by bank transfer. With Stripe, the form and any uploaded files can wait until Stripe confirms the payment.', 'holmesportfolio-forms' ) ), array( __( 'Booking calendars', 'holmesportfolio-forms' ), __( 'Booking calendars for appointments, rooms, tables and holiday stays, with a link customers can use to cancel.', 'holmesportfolio-forms' ) ), array( __( 'Signatures and PDFs', 'holmesportfolio-forms' ), __( 'Signatures, drawn or typed, and a PDF (Portable Document Format) file of each entry\'s answers to download or attach to an email.', 'holmesportfolio-forms' ) ), array( __( 'Save and resume', 'holmesportfolio-forms' ), __( 'Save and resume, so people can finish a long form later, and repeatable groups, so they can fill in the same set of questions more than once.', 'holmesportfolio-forms' ) ), array( __( 'Statuses and notes', 'holmesportfolio-forms' ), __( 'A status and internal notes on each entry, messages to the person who sent it, and a staff-only extra form attached to an entry.', 'holmesportfolio-forms' ) ), array( __( 'Staff roles', 'holmesportfolio-forms' ), __( 'Four ready-made staff roles, so people can build forms or work entries without being site administrators, and a tick list of who sees which forms.', 'holmesportfolio-forms' ) ), array( __( 'Polls and maps', 'holmesportfolio-forms' ), __( 'Polls that count votes across every submission, and map questions where people mark where something is.', 'holmesportfolio-forms' ) ), array( __( 'Webhooks and mailing lists', 'holmesportfolio-forms' ), __( 'Webhooks that send each entry to another system, and Mailchimp and Brevo sign-ups for people who tick your consent box.', 'holmesportfolio-forms' ) ), array( __( 'Analytics', 'holmesportfolio-forms' ), __( 'Analytics as daily totals: where people stop, how long a form takes and which optional questions get skipped.', 'holmesportfolio-forms' ) ), array( __( 'Activity log', 'holmesportfolio-forms' ), __( 'A fuller activity log for admins: who opened an entry, who downloaded a PDF, and changes to settings and roles.', 'holmesportfolio-forms' ) ), ); $casework_items = array( array( __( 'Pick up and hand over', 'holmesportfolio-forms' ), __( 'Staff pick up a case, work it and close it, release it back to the pool, or hand it to a named colleague. Every hand-over is logged on the case.', 'holmesportfolio-forms' ) ), array( __( 'Teams', 'holmesportfolio-forms' ), __( 'Teams see only their own forms, and can pass a case to another team when you allow it.', 'holmesportfolio-forms' ) ), array( __( 'Response times', 'holmesportfolio-forms' ), __( 'A response-time target for each form, in days or hours, counting working days or every day. Cases past their target show as overdue, and a reminder email goes out.', 'holmesportfolio-forms' ) ), array( __( 'Complaints and subject access', 'holmesportfolio-forms' ), __( 'Complaints in two stages, each with its own target, and a clock for subject access requests, where someone asks for a copy of their personal data. The dates follow the UK General Data Protection Regulation (UK GDPR) as we understand it. Check them against the Information Commissioner\'s Office (ICO) guidance.', 'holmesportfolio-forms' ) ), array( __( 'Case reports', 'holmesportfolio-forms' ), __( 'Case reports by month, quarter, financial year or calendar year: cases received, closed, closed on time and still open, with the median time to close. Download the figures as a spreadsheet (CSV) file.', 'holmesportfolio-forms' ) ), array( __( 'Reopening', 'holmesportfolio-forms' ), __( 'You decide who can reopen a closed case.', 'holmesportfolio-forms' ) ), array( __( 'Replies and sign-in', 'holmesportfolio-forms' ), __( 'The person who sent the form can reply through a one-time link, or sign in to see the status of their own cases.', 'holmesportfolio-forms' ) ), array( __( 'Maps of reports', 'holmesportfolio-forms' ), __( 'A map of reported locations for staff, and a public map that shows only the reports you choose.', 'holmesportfolio-forms' ) ), ); $cards = static function ( array $items ) { $html = '
'; foreach ( $items as $item ) { $html .= '
' . esc_html( $item[0] ) . '' . esc_html( $item[1] ) . '
'; } return $html . '
'; }; $page_url = 'https://holmesportfolio.co.uk/holmesportfolio-forms/'; $html = '
' . self::brand_header_html() . '

' . esc_html__( 'Pro and Casework', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'You already have the whole form builder. If your team also takes payments, books appointments or works each entry as a case, we\'re building a paid add-on plugin for that. It comes in two plans, Pro and Casework. Here\'s what each one adds.', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'What Pro adds', 'holmesportfolio-forms' ) . '

' . $cards( $pro_items ) . '
' . '

' . esc_html__( 'What Casework adds', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'Casework includes everything in Pro, and adds a case system for a team.', 'holmesportfolio-forms' ) . '

' . $cards( $casework_items ) . '
' . '

' . esc_html__( 'Where to read more', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'Pro and Casework are the two plans of a separate add-on plugin from us. It isn\'t on sale yet, and what it includes may change before launch. Nothing on this site needs it. The free plugin is complete on its own, and every form you build with it keeps working whether or not you ever install the add-on.', 'holmesportfolio-forms' ) . '

' . '

' . esc_html__( 'Read more on the HolmesPortfolio Forms page on our website', 'holmesportfolio-forms' ) . self::new_tab_hint() . '

' . '
'; echo wp_kses( $html, HP_Forms_Kses::admin() ); } private function render_email_test() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-mailer.php'; $from_email = HP_Forms_Mailer::default_from_email(); $from_email = '' !== $from_email ? $from_email : HP_Forms_Mailer::fallback_from_email(); echo '

' . esc_html__( 'Test your emails', 'holmesportfolio-forms' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect $notice = isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : ''; $test_result = (string) get_transient( 'holmesforms_test_mail_' . get_current_user_id() ); if ( in_array( $notice, array( 'test_sent', 'test_failed' ), true ) && '' !== $test_result ) { delete_transient( 'holmesforms_test_mail_' . get_current_user_id() ); echo '

' . esc_html( $test_result ) . '

'; } /* translators: %s: the email address form emails are sent from */ echo '

' . sprintf( esc_html__( 'Form emails are sent from %s.', 'holmesportfolio-forms' ), '' . esc_html( $from_email ) . '' ) . '

'; echo ''; echo '

' . esc_html__( 'If a test email goes to spam or never arrives, set up a mail plugin that sends through a real mailbox on your own domain, using the Simple Mail Transfer Protocol (SMTP).', 'holmesportfolio-forms' ) . '

'; $current_user = wp_get_current_user(); echo '
'; wp_nonce_field( 'holmesforms_test_email' ); echo ''; echo '

'; echo '

'; echo '

'; echo '
'; } /** @return string[] Plain lines about the domain's SPF and DMARC records. */ private static function describe_mail_dns( $domain ) { $domain = strtolower( (string) $domain ); if ( in_array( $domain, array( 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'hotmail.co.uk', 'live.com', 'yahoo.com', 'yahoo.co.uk', 'icloud.com', 'aol.com' ), true ) ) { return array( __( 'This is a personal email service. Emails your website sends "from" it are usually blocked or marked as spam. Use an address on your own domain.', 'holmesportfolio-forms' ) ); } if ( '' === $domain || ! function_exists( 'dns_get_record' ) ) { return array( __( 'This server cannot look up email records, so the check is skipped.', 'holmesportfolio-forms' ) ); } $cached = get_transient( 'holmesforms_mail_dns_' . md5( $domain ) ); if ( is_array( $cached ) ) { return $cached; } $has_txt_record = static function ( $host, $prefix ) { $records = @dns_get_record( $host, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failed lookup warns; it counts as no record. foreach ( (array) $records as $record ) { if ( isset( $record['txt'] ) && 0 === stripos( $record['txt'], $prefix ) ) { return true; } } return false; }; $domain_parts = explode( '.', $domain ); $parent_domain = count( $domain_parts ) > 2 ? implode( '.', array_slice( $domain_parts, 1 ) ) : ''; $has_spf = $has_txt_record( $domain, 'v=spf1' ); $has_dmarc = $has_txt_record( '_dmarc.' . $domain, 'v=DMARC1' ) || ( '' !== $parent_domain && $has_txt_record( '_dmarc.' . $parent_domain, 'v=DMARC1' ) ); $lines = array( $has_spf /* translators: %s: email domain */ ? sprintf( __( 'Sender Policy Framework (SPF) record for %s: found.', 'holmesportfolio-forms' ), $domain ) /* translators: %s: email domain */ : sprintf( __( 'Sender Policy Framework (SPF) record for %s: missing. Many inboxes send these emails to spam until the domain has one.', 'holmesportfolio-forms' ), $domain ), $has_dmarc ? __( 'Domain-based Message Authentication, Reporting and Conformance (DMARC) record: found.', 'holmesportfolio-forms' ) : __( 'Domain-based Message Authentication, Reporting and Conformance (DMARC) record: missing. Gmail and other large inboxes expect one. Ask whoever runs your domain to add it.', 'holmesportfolio-forms' ), ); set_transient( 'holmesforms_mail_dns_' . md5( $domain ), $lines, HOUR_IN_SECONDS ); return $lines; } public function handle_test_email() { check_admin_referer( 'holmesforms_test_email' ); if ( ! current_user_can( apply_filters( 'holmesforms_settings_menu_cap', self::CAP ) ) ) { wp_die( esc_html__( 'Not allowed.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $to = isset( $_POST['to'] ) ? sanitize_email( wp_unslash( $_POST['to'] ) ) : ''; $back_url = admin_url( 'admin.php?page=' . self::SETTINGS_SLUG . '&tab=email' ); if ( ! is_email( $to ) ) { set_transient( 'holmesforms_test_mail_' . get_current_user_id(), __( 'Enter a valid email address to send the test to.', 'holmesportfolio-forms' ), self::TEST_MAIL_NOTICE_SECONDS ); wp_safe_redirect( add_query_arg( 'holmesforms_notice', 'test_failed', $back_url ) . '#hpf-email-test' ); exit; } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-mailer.php'; $result = HP_Forms_Mailer::send_test( $to ); $message = is_wp_error( $result ) /* translators: %s: the reason the email failed */ ? sprintf( __( 'The test email could not be sent: %s', 'holmesportfolio-forms' ), $result->get_error_message() ) /* translators: %s: email address */ : sprintf( __( 'Test email sent to %s. It can take a few minutes to arrive, so check the spam folder too.', 'holmesportfolio-forms' ), $to ); set_transient( 'holmesforms_test_mail_' . get_current_user_id(), $message, self::TEST_MAIL_NOTICE_SECONDS ); wp_safe_redirect( add_query_arg( 'holmesforms_notice', is_wp_error( $result ) ? 'test_failed' : 'test_sent', $back_url ) . '#hpf-email-test' ); exit; } public function redirect_core_screens() { global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which post type the list shows $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : ''; if ( HOLMESFORMS_POST_TYPE !== $post_type || ! in_array( $pagenow, array( 'edit.php', 'post-new.php' ), true ) ) { return; } $slug = 'post-new.php' === $pagenow ? self::BUILD_SLUG : self::MENU_SLUG; wp_safe_redirect( admin_url( 'admin.php?page=' . $slug ) ); exit; } /* Assets */ public function enqueue_assets( $hook ) { $is_builder = false !== strpos( $hook, self::BUILD_SLUG ); $is_describe = false !== strpos( $hook, HP_Forms_Describe::SLUG ); $is_ours = $is_builder || $is_describe || false !== strpos( $hook, self::MENU_SLUG ) || false !== strpos( $hook, self::ENTRIES_SLUG ) || false !== strpos( $hook, self::SETTINGS_SLUG ) || false !== strpos( $hook, self::ADDONS_SLUG ); if ( ! $is_ours ) { return; } wp_enqueue_style( 'holmesforms-admin', HOLMESFORMS_URL . 'assets/css/admin.css', array(), holmesforms_asset_version( 'assets/css/admin.css' ) ); self::enqueue_screen_scripts(); wp_enqueue_script( 'holmesforms-buttons', HOLMESFORMS_URL . 'assets/js/holmesforms-buttons.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-buttons.js' ), true ); if ( $is_describe ) { wp_enqueue_script( 'holmesforms-describe', HOLMESFORMS_URL . 'assets/js/holmesforms-describe.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-describe.js' ), true ); } $is_forms_list = ! $is_builder && false !== strpos( $hook, self::MENU_SLUG ) && false === strpos( $hook, self::ENTRIES_SLUG ) && false === strpos( $hook, self::SETTINGS_SLUG ); if ( $is_builder || $is_forms_list ) { wp_enqueue_script( 'holmesforms-admin-list', HOLMESFORMS_URL . 'assets/js/admin-list.js', array( 'holmesforms-confirm' ), holmesforms_asset_version( 'assets/js/admin-list.js' ), true ); wp_localize_script( 'holmesforms-admin-list', 'holmesformsList', array( 'copied' => __( 'Copied', 'holmesportfolio-forms' ), 'failed' => __( 'Press Ctrl+C', 'holmesportfolio-forms' ), ) ); } if ( false !== strpos( $hook, 'holmesforms' ) ) { wp_enqueue_script( 'holmesforms-soft-reload', HOLMESFORMS_URL . 'assets/js/holmesforms-soft-reload.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-soft-reload.js' ), true ); } if ( false !== strpos( $hook, self::ENTRIES_SLUG ) || false !== strpos( $hook, self::SETTINGS_SLUG ) ) { wp_enqueue_script( 'holmesforms-admin-entries', HOLMESFORMS_URL . 'assets/js/admin-entries.js', array( 'holmesforms-confirm' ), holmesforms_asset_version( 'assets/js/admin-entries.js' ), true ); $form_titles = array(); foreach ( get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'any', 'numberposts' => -1, 'no_found_rows' => true, 'fields' => 'ids', ) ) as $existing_form_id ) { $title = strtolower( trim( (string) get_the_title( $existing_form_id ) ) ); if ( '' !== $title ) { $form_titles[ $title ] = 1; } } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; $block_names = array(); foreach ( HP_Forms_Presets::all() as $block ) { $block_names[ strtolower( trim( (string) $block['name'] ) ) ] = 1; } wp_localize_script( 'holmesforms-admin-entries', 'holmesformsEntries', array( 'exportConfirm' => __( 'Choose what to include, then download.', 'holmesportfolio-forms' ), 'exportTitle' => __( 'Export CSV', 'holmesportfolio-forms' ), 'exportLabel' => __( 'Download CSV', 'holmesportfolio-forms' ), 'exportNoneSelected' => __( 'Nothing was exported. Tick the entries you want first.', 'holmesportfolio-forms' ), 'spamConfirm' => __( 'These entries leave the list and are deleted for good after 30 days, unless you restore them first.', 'holmesportfolio-forms' ), 'maxUpload' => wp_max_upload_size(), 'tooBig' => __( 'This file is larger than this server accepts for uploads.', 'holmesportfolio-forms' ), 'notRecognised' => __( 'That file is not a HolmesPortfolio Forms export.', 'holmesportfolio-forms' ), 'allForms' => __( 'All forms', 'holmesportfolio-forms' ), 'existingForms' => array_keys( $form_titles ), 'existingBlocks' => array_keys( $block_names ), 'ackReplace' => __( 'I understand the matching forms and blocks listed above will be overwritten', 'holmesportfolio-forms' ), ) ); } if ( false !== strpos( $hook, self::ENTRIES_SLUG ) ) { wp_enqueue_script( 'holmesforms-admin-entry-tabs', HOLMESFORMS_URL . 'assets/js/admin-entry-tabs.js', array(), holmesforms_asset_version( 'assets/js/admin-entry-tabs.js' ), true ); } if ( false !== strpos( $hook, self::SETTINGS_SLUG ) ) { wp_enqueue_script( 'holmesforms-restore', HOLMESFORMS_URL . 'assets/js/holmesforms-restore.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-restore.js' ), true ); wp_enqueue_script( 'holmesforms-email-example', HOLMESFORMS_URL . 'assets/js/holmesforms-email-example.js', array( 'holmesforms-confirm' ), holmesforms_asset_version( 'assets/js/holmesforms-email-example.js' ), true ); wp_enqueue_script( 'holmesforms-email-look', HOLMESFORMS_URL . 'assets/js/holmesforms-email-look.js', array( 'holmesforms-confirm' ), holmesforms_asset_version( 'assets/js/holmesforms-email-look.js' ), true ); wp_enqueue_script( 'holmesforms-settings-help', HOLMESFORMS_URL . 'assets/js/holmesforms-settings-help.js', array(), holmesforms_asset_version( 'assets/js/holmesforms-settings-help.js' ), true ); wp_localize_script( 'holmesforms-settings-help', 'holmesformsSettingsHelp', array( 'guideUrl' => self::guide_url(), 'map' => self::settings_help_map(), 'text' => self::help_strings(), ) ); } if ( ! $is_builder ) { return; } wp_enqueue_script( 'holmesforms-admin-builder', HOLMESFORMS_URL . 'assets/js/admin-builder.js', array(), holmesforms_asset_version( 'assets/js/admin-builder.js' ), true ); wp_set_script_translations( 'holmesforms-admin-builder', 'holmesportfolio-forms', HOLMESFORMS_DIR . 'languages' ); $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows if ( $form_id ) { $definition = $this->export_definition( $form_id ); } else { $preview_definition = HP_Forms_Tour::preview_definition(); $definition = $preview_definition ? $preview_definition : $this->blank_definition(); } wp_localize_script( 'holmesforms-admin-builder', 'holmesformsBuilder', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'post' => admin_url( 'admin-post.php' ), 'presetNonce' => wp_create_nonce( 'holmesforms_presets' ), 'defaultFromEmail' => (string) $this->get_setting( 'from_email' ), 'a11yNonce' => wp_create_nonce( 'holmesforms_a11y' ), 'formulaNonce' => wp_create_nonce( 'holmesforms_formula' ), 'bankHolidayModes' => HP_Forms_Holidays::modes(), 'bankHolidaySite' => HP_Forms_Holidays::site_region(), 'accountAutofill' => (bool) apply_filters( 'holmesforms_account_autofill_available', false ), 'typeDetails' => HP_Forms_Components::registered_builder_details(), 'a11yGuideUrl' => 'https://www.w3.org/WAI/tutorials/forms/', 'guideUrl' => self::guide_url(), 'helpText' => self::help_strings(), 'definition' => $definition, 'formTitle' => $form_id ? get_the_title( $form_id ) : '', 'siteOffsetSeconds' => (int) wp_timezone()->getOffset( new DateTime( 'now' ) ), 'formulaFunctions' => class_exists( 'HP_Forms_Logic' ) ? HP_Forms_Logic::FORMULA_FUNCTIONS : array(), 'formId' => $form_id, 'hasEntries' => $form_id && HP_Forms_Storage::count( array( 'form_id' => (int) $form_id ) ) > 0, 'practice' => '' !== HP_Forms_Tour::running_tutorial() && ! $form_id, 'touring' => '' !== HP_Forms_Tour::running_tutorial(), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, opens the Issues panel from a link 'openIssues' => ! empty( $_GET['holmesforms_issues'] ), 'revNonce' => wp_create_nonce( 'holmesforms_revisions' ), 'lockNonce' => wp_create_nonce( 'holmesforms_lock' ), 'lockPage' => self::lock_page_token(), 'groupTip' => ! get_user_meta( get_current_user_id(), 'holmesforms_group_tip_done', true ), 'tipNonce' => wp_create_nonce( 'holmesforms_tip_done' ), 'restoreNonce' => $form_id ? wp_create_nonce( 'holmesforms_restore_revision_' . $form_id ) : '', 'lockedByOther' => $form_id ? (bool) wp_check_post_lock( $form_id ) : false, 'componentTypes' => $this->component_type_list(), 'groups' => HP_Forms_Components::groups(), 'operators' => $this->operator_list(), 'typeSchemas' => HP_Forms_Components::registered_schemas(), /** * @param array[] $uses Each { where, keys: string[], fix }. * @param int $form_id 0 while the form is new. */ 'keyUses' => array_values( (array) apply_filters( 'holmesforms_builder_key_uses', array(), $form_id ) ), 'emailPdf' => (bool) apply_filters( 'holmesforms_email_action_pdf', false ), 'bankHolidays' => (bool) apply_filters( 'holmesforms_bank_holidays_available', false ), 'strings' => array( 'newPage' => __( 'New page', 'holmesportfolio-forms' ), 'confirmDeletePage' => __( 'This deletes the page and every field on it. This cannot be undone.', 'holmesportfolio-forms' ), 'presetName' => __( 'Name this block', 'holmesportfolio-forms' ), ), ) ); } /* Screens */ public function screen_list() { if ( ! current_user_can( self::CAP ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $group = isset( $_GET['holmesforms_group'] ) ? sanitize_text_field( wp_unslash( $_GET['holmesforms_group'] ) ) : ''; $paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:enable WordPress.Security.NonceVerification.Recommended $scope = self::editable_form_ids(); $query = self::query_listed_forms( $search, $group, $paged, $scope ); $forms = self::add_form_searched_by_id( $query->posts, $search, $scope ); $group_terms = get_terms( array( 'taxonomy' => HOLMESFORMS_FORM_GROUP_TAX, 'hide_empty' => true, ) ); $group_terms = is_wp_error( $group_terms ) ? array() : $group_terms; echo wp_kses( '
' . self::brand_header_html() . '

' . esc_html__( 'Forms', 'holmesportfolio-forms' ) . '

', HP_Forms_Kses::admin() ); echo '' . esc_html__( 'Add New', 'holmesportfolio-forms' ) . ''; if ( class_exists( 'HP_Forms_Tour' ) ) { echo wp_kses( ' ' . HP_Forms_Tour::menu_html(), HP_Forms_Kses::admin() ); } echo ' ' . esc_html__( 'Describe your form', 'holmesportfolio-forms' ) . ''; echo '
'; $this->render_list_notice(); self::render_quick_import(); self::render_list_search( $search, $group, $group_terms ); $columns = array( 'title' => __( 'Form title', 'holmesportfolio-forms' ), 'sc' => __( 'Shortcode', 'holmesportfolio-forms' ), 'entries' => __( 'Entries', 'holmesportfolio-forms' ), 'issues' => __( 'Form check', 'holmesportfolio-forms' ), 'modified' => __( 'Modified', 'holmesportfolio-forms' ), 'group' => __( 'Group', 'holmesportfolio-forms' ), ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; if ( ! $forms ) { $empty_message = '' !== $search || '' !== $group ? __( 'No forms match.', 'holmesportfolio-forms' ) : __( 'No forms yet.', 'holmesportfolio-forms' ); echo ''; } foreach ( $forms as $form ) { $this->render_form_row( $form, $columns ); } echo '
' . esc_html( $columns['title'] ) . '' . esc_html__( 'Shortcode', 'holmesportfolio-forms' ) . '' . esc_html__( 'Entries', 'holmesportfolio-forms' ) . '' . esc_html__( 'Form check', 'holmesportfolio-forms' ) . '' . esc_html__( 'Modified', 'holmesportfolio-forms' ) . '' . esc_html__( 'Group', 'holmesportfolio-forms' ) . '
' . esc_html( $empty_message ) . '
'; if ( $query->max_num_pages > 1 ) { self::render_list_pagination( $search, $group, $paged, (int) $query->max_num_pages ); } echo '
'; } private static function query_listed_forms( $search, $group, $paged, $scope ) { $args = array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => array( 'publish', 'draft' ), 'posts_per_page' => self::MAX_LISTED_FORMS, 'paged' => $paged, 'orderby' => 'modified', 'order' => 'DESC', 's' => $search, 'ignore_sticky_posts' => true, ); if ( '' !== $group ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the Forms list filtered by group $args['tax_query'] = array( array( 'taxonomy' => HOLMESFORMS_FORM_GROUP_TAX, 'field' => 'slug', 'terms' => sanitize_title( $group ), ), ); } if ( is_array( $scope ) ) { $args['post__in'] = $scope ? $scope : array( 0 ); } return new WP_Query( $args ); } private static function add_form_searched_by_id( array $forms, $search, $scope ) { $searched_id = 0; if ( preg_match( '/\bid=["\']?(\d+)/', $search, $shortcode_match ) ) { $searched_id = (int) $shortcode_match[1]; } elseif ( holmesforms_is_digits( trim( $search ) ) ) { $searched_id = (int) trim( $search ); } if ( ! $searched_id || ( is_array( $scope ) && ! in_array( $searched_id, $scope, true ) ) ) { return $forms; } foreach ( $forms as $listed_form ) { if ( (int) $listed_form->ID === $searched_id ) { return $forms; } } $searched_form = get_post( $searched_id ); if ( $searched_form && HOLMESFORMS_POST_TYPE === $searched_form->post_type && in_array( $searched_form->post_status, array( 'publish', 'draft' ), true ) ) { array_unshift( $forms, $searched_form ); } return $forms; } private function render_list_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect $notice = isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : ''; $messages = array( 'imported_one' => array( 'success', __( 'Form imported.', 'holmesportfolio-forms' ) ), 'imported_over' => array( 'success', __( 'Form imported. It replaced the form of the same name. The previous version is in that form’s history.', 'holmesportfolio-forms' ) ), 'import_identical' => array( 'info', __( 'That form is already here, unchanged. Nothing to import.', 'holmesportfolio-forms' ) ), 'import_expired' => array( 'error', __( 'That import was no longer waiting. Please choose the file again.', 'holmesportfolio-forms' ) ), 'import_error' => array( 'error', __( 'That file is not a HolmesPortfolio Forms export, or it holds no form.', 'holmesportfolio-forms' ) ), ); if ( 'import_conflict' === $notice ) { $this->render_import_conflict_notice(); return; } if ( isset( $messages[ $notice ] ) ) { echo '

' . esc_html( $messages[ $notice ][1] ) . '

'; return; } if ( '' === $notice ) { return; } echo '

' . esc_html__( 'Form saved.', 'holmesportfolio-forms' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, the saved form's id from the redirect $saved_form_id = isset( $_GET['holmesforms_saved_form'] ) ? absint( $_GET['holmesforms_saved_form'] ) : 0; if ( $saved_form_id ) { echo ''; } } private static function render_quick_import() { echo '
'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, opens the import box from a link echo '' . esc_html__( 'Import a form', 'holmesportfolio-forms' ) . ''; echo '
'; echo ''; wp_nonce_field( 'holmesforms_import_form' ); echo '

'; submit_button( __( 'Import form', 'holmesportfolio-forms' ), 'secondary', 'submit', false ); echo '

' . esc_html__( 'Adds one form from a HolmesPortfolio Forms export file. A form with the same name is overwritten. You confirm first, and its old version stays in history. To keep both, rename the form in the file. Settings and saved blocks are left untouched.', 'holmesportfolio-forms' ) . '

'; echo '
'; } private static function render_list_search( $search, $group, array $group_terms ) { echo '
'; echo ''; echo ' '; echo ' '; if ( $group_terms ) { echo ''; echo ' '; } echo ''; if ( '' !== $search || '' !== $group ) { echo ' ' . esc_html__( 'Clear', 'holmesportfolio-forms' ) . ''; } echo '
'; } private function render_form_row( WP_Post $form, array $columns ) { $edit_url = admin_url( 'admin.php?page=' . self::BUILD_SLUG . '&form=' . $form->ID ); $entry_count = HP_Forms_Storage::count( array( 'form_id' => $form->ID ) ); $delete_url = wp_nonce_url( admin_url( 'admin-post.php?action=holmesforms_delete_form&form=' . $form->ID ), 'holmesforms_delete_form_' . $form->ID ); $export_url = wp_nonce_url( admin_url( 'admin-post.php?action=holmesforms_export_form&form=' . $form->ID ), 'holmesforms_export_form_' . $form->ID ); $delete_message = $entry_count > 0 ? sprintf( /* translators: %d: number of entries */ _n( 'Delete this form? Its %d stored entry will be permanently deleted too. This cannot be undone.', 'Delete this form? Its %d stored entries will be permanently deleted too. This cannot be undone.', $entry_count, 'holmesportfolio-forms' ), $entry_count ) : __( 'Delete this form? This cannot be undone.', 'holmesportfolio-forms' ); $form_title = get_the_title( $form ) ? get_the_title( $form ) : __( '(no title)', 'holmesportfolio-forms' ); $named = static function ( $action, $title ) { /* translators: 1: an action such as Edit, 2: form name */ return esc_attr( sprintf( __( '%1$s “%2$s”', 'holmesportfolio-forms' ), $action, $title ) ); }; echo ''; echo '' . esc_html( $form_title ) . ''; echo wp_kses( '
' . esc_html__( 'Edit', 'holmesportfolio-forms' ) . ' | ', HP_Forms_Kses::admin() ); echo wp_kses( '' . esc_html__( 'Duplicate', 'holmesportfolio-forms' ) . ' | ', HP_Forms_Kses::admin() ); echo wp_kses( '' . esc_html__( 'Export', 'holmesportfolio-forms' ) . ' | ', HP_Forms_Kses::admin() ); echo wp_kses( '' . esc_html__( 'Delete', 'holmesportfolio-forms' ) . '
', HP_Forms_Kses::admin() ); echo '[holmesforms_form id="' . (int) $form->ID . '"] ' . ''; self::render_add_to_page_link( $form->ID, $form_title ); echo ''; /* translators: 1: number of entries, 2: form name */ $entries_name = sprintf( _n( '%1$d entry for “%2$s”', '%1$d entries for “%2$s”', $entry_count, 'holmesportfolio-forms' ), $entry_count, $form_title ); echo '' . (int) $entry_count . ''; echo wp_kses( '' . $this->a11y_badge( $form->ID, $form_title ) . '', HP_Forms_Kses::admin() ); echo '' . esc_html( holmesforms_date( get_post_modified_time( 'U', true, $form ), true ) ) . ''; $row_terms = wp_get_object_terms( $form->ID, HOLMESFORMS_FORM_GROUP_TAX ); if ( ! is_wp_error( $row_terms ) && $row_terms ) { $row_term = $row_terms[0]; echo '' . esc_html( $row_term->name ) . ''; } else { echo '' . esc_html__( 'None', 'holmesportfolio-forms' ) . ''; } echo ''; } private static function render_list_pagination( $search, $group, $paged, $total_pages ) { $base_url = add_query_arg( array_filter( array( 'page' => self::MENU_SLUG, 's' => rawurlencode( $search ), 'holmesforms_group' => rawurlencode( $group ), ) ), admin_url( 'admin.php' ) ); $links = paginate_links( array( 'base' => add_query_arg( 'paged', '%#%', $base_url ), 'format' => '', 'current' => $paged, 'total' => $total_pages, ) ); echo '
' . wp_kses_post( $links ) . '
'; } private function a11y_badge( $form_id, $form_title = '' ) { $summary = get_post_meta( $form_id, '_holmesforms_a11y', true ); if ( ! is_array( $summary ) || ! isset( $summary['error'], $summary['warning'] ) ) { $form = HP_Forms_Form::get( $form_id ); $summary = $form ? HP_Forms_A11y::count_by_severity( HP_Forms_A11y::review( $form ) ) : array( 'error' => 0, 'warning' => 0, ); } $note_count = isset( $summary['note'] ) ? (int) $summary['note'] : 0; $spoken = static function ( $meaning ) use ( $form_title ) { /* translators: 1: what the badge means, such as No problems found, 2: form name */ $text = '' !== $form_title ? sprintf( __( '%1$s in “%2$s”', 'holmesportfolio-forms' ), $meaning, $form_title ) : $meaning; return '' . esc_html( $text ) . ''; }; $actionable_count = (int) $summary['error'] + (int) $summary['warning']; if ( 0 === $actionable_count ) { if ( $note_count > 0 ) { /* translators: %d: number of notes */ $meaning = sprintf( _n( '%d note: a suggestion, nothing to fix', '%d notes: suggestions, nothing to fix', $note_count, 'holmesportfolio-forms' ), $note_count ); return '' . $spoken( $meaning ); } $meaning = __( 'No problems found', 'holmesportfolio-forms' ); return '' . $spoken( $meaning ); } /* translators: 1: error count, 2: warning count */ $meaning = sprintf( _n( '%1$d error, %2$d warning', '%1$d errors, %2$d warnings', $actionable_count, 'holmesportfolio-forms' ), (int) $summary['error'], (int) $summary['warning'] ); return '' . $spoken( $meaning ); } public function builder_admin_title( $admin_title, $title ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows if ( isset( $_GET['page'], $_GET['form'] ) && self::BUILD_SLUG === $_GET['page'] && absint( $_GET['form'] ) ) { return str_replace( $title, __( 'Edit form', 'holmesportfolio-forms' ), $admin_title ); } return $admin_title; } public function screen_builder() { if ( ! current_user_can( self::CAP ) ) { return; } $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows self::die_unless_editable_form( $form_id ); $title = $form_id ? __( 'Edit form', 'holmesportfolio-forms' ) : __( 'Create New form', 'holmesportfolio-forms' ); // Opening the screen only reads the lock; the builder's first lock check, a nonce-checked request, takes it. $lock_holder = $form_id ? self::other_editor_id( $form_id ) : 0; echo '
'; echo wp_kses( self::brand_header_html(), HP_Forms_Kses::admin() ); echo '

' . esc_html( $title ) . '

'; if ( $form_id ) { $shortcode = '[holmesforms_form id="' . (int) $form_id . '"]'; echo '

' . esc_html( $shortcode ) . ' ' . ''; self::render_add_to_page_link( $form_id ); echo '

'; } echo '
'; if ( $lock_holder ) { self::render_lock_banner( $form_id, $lock_holder ); } self::render_nojs_builder_notice( $form_id ); self::render_builder_notices( $form_id ); echo '
'; echo ''; echo ''; wp_nonce_field( 'holmesforms_save_form', 'holmesforms_nonce' ); echo ''; echo ''; echo ''; self::render_title_and_group_fields( $form_id ); if ( $form_id && class_exists( 'HP_Forms_Plugin' ) && HP_Forms_Plugin::is_bundled_form( $form_id ) ) { echo '

' . esc_html__( 'This is one of the starter forms.', 'holmesportfolio-forms' ) . ' ' . esc_html__( '“Restore starter content” (Settings → General, or the setup wizard) can reset a starter form to its built-in version. Changing this one? Rename it below first. A restore then leaves yours alone and adds a fresh copy of the original.', 'holmesportfolio-forms' ) . '

' . esc_html__( 'This is an example, written in good faith. It is not legal advice. Check it and set your own before you publish. We can’t promise its deadlines or legal wording are right for you.', 'holmesportfolio-forms' ) . '

'; } echo '

' . esc_html__( 'Loading the builder…', 'holmesportfolio-forms' ) . '

'; self::render_builder_savebar( $form_id, (bool) $lock_holder ); echo '
'; } private static function die_unless_editable_form( $form_id ) { if ( $form_id && HOLMESFORMS_POST_TYPE !== get_post_type( $form_id ) ) { wp_die( esc_html__( 'That is not a form.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } if ( $form_id && ! self::is_editable_form( $form_id ) ) { wp_die( esc_html__( 'That form isn\'t shared with you.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } } /** @return bool A form this user may change, within any add-on scope. */ public static function is_editable_form( $form_id ) { $form_id = (int) $form_id; if ( ! $form_id || HOLMESFORMS_POST_TYPE !== get_post_type( $form_id ) ) { return false; } $scope = self::editable_form_ids(); return null === $scope || in_array( $form_id, $scope, true ); } /** @return int The other user who has the form open in the builder, or 0. */ private static function other_editor_id( $form_id ) { $lock_holder = (int) wp_check_post_lock( $form_id ); return get_current_user_id() !== $lock_holder ? $lock_holder : 0; } private static function user_display_name( $user_id, $fallback ) { $user = get_userdata( $user_id ); return $user ? $user->display_name : $fallback; } private static function render_lock_banner( $form_id, $lock_holder ) { $lock_value = get_post_meta( $form_id, '_edit_lock', true ); $locked_since = ''; if ( $lock_value ) { $colon_position = strpos( (string) $lock_value, ':' ); $locked_at = (int) substr( (string) $lock_value, 0, $colon_position ? $colon_position : strlen( (string) $lock_value ) ); $locked_since = wp_date( 'H:i', $locked_at ); } echo '

' . esc_html( sprintf( /* translators: 1: display name, 2: local time */ __( 'This form is being edited by %1$s (locked at %2$s).', 'holmesportfolio-forms' ), self::user_display_name( $lock_holder, __( 'another user', 'holmesportfolio-forms' ) ), $locked_since ) ) . ' ' . esc_html__( 'Saving is disabled until you take over. Taking over removes them from the builder and any unsaved changes they have are lost.', 'holmesportfolio-forms' ) . '

' . '' . '' . '' . '

'; } private static function render_builder_notices( $form_id ) { if ( $form_id && 'auto-draft' === get_post_status( $form_id ) ) { echo '

' . esc_html__( 'This form is not saved yet. Press Save to keep it.', 'holmesportfolio-forms' ) . '

'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect $notice = isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : ''; if ( 'saved' === $notice || 'restored' === $notice ) { $message = 'restored' === $notice ? __( 'Version restored. Your previous form was saved to the history list first.', 'holmesportfolio-forms' ) : __( 'Form saved.', 'holmesportfolio-forms' ); echo '

' . esc_html( $message ) . '

'; $renamed = $form_id ? get_transient( 'holmesforms_renamed_' . get_current_user_id() . '_' . $form_id ) : false; if ( is_array( $renamed ) && $renamed ) { delete_transient( 'holmesforms_renamed_' . get_current_user_id() . '_' . $form_id ); $lines = array(); foreach ( $renamed as $from => $to ) { $lines[] = in_array( $from, HP_Forms_Form::RESERVED_KEYS, true ) /* translators: 1: the old Data Name, 2: the new Data Name */ ? sprintf( __( 'The Data Name "%1$s" belongs to the built-in [%1$s] token, so it is now "%2$s".', 'holmesportfolio-forms' ), $from, $to ) /* translators: 1: the old Data Name, 2: the new Data Name */ : sprintf( __( 'Another question already uses the Data Name "%1$s", so this one is now "%2$s".', 'holmesportfolio-forms' ), $from, $to ); } echo '

' . esc_html( implode( ' ', $lines ) ) . ' ' . esc_html__( 'Check any rule, email or message that used the old name, and update it to the new one.', 'holmesportfolio-forms' ) . '

'; } } elseif ( 'copied' === $notice && $form_id ) { echo '

' . esc_html__( 'Your copy is ready. Give it a new title below. It starts with no entries.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'It has the same reference prefix as the original. To tell the two forms\' entries apart, give this one its own prefix on the Setup tab.', 'holmesportfolio-forms' ) . '

'; } } private static function render_title_and_group_fields( $form_id ) { $current_group = ''; if ( $form_id ) { $terms = wp_get_object_terms( $form_id, HOLMESFORMS_FORM_GROUP_TAX, array( 'fields' => 'names' ) ); $current_group = ( ! is_wp_error( $terms ) && $terms ) ? (string) $terms[0] : ''; } $all_groups = self::used_group_names(); echo '
'; echo '

'; echo '

'; echo ''; echo ''; echo ''; foreach ( $all_groups as $group_name ) { echo ''; } echo ''; echo '' . esc_html__( 'Optional. Put related forms in a group, for example Contact forms, Highways or Licensing. Then you can filter the Forms list by group.', 'holmesportfolio-forms' ) . '

'; echo '
'; } private static function render_builder_savebar( $form_id, $is_locked ) { echo '

'; $save_disabled_attributes = $is_locked ? ' disabled aria-describedby="hpf-lock-why"' : ''; echo wp_kses( ' ', HP_Forms_Kses::admin() ); echo wp_kses( '', HP_Forms_Kses::admin() ); if ( $is_locked ) { echo ' ' . esc_html__( 'Take over editing (at the top) to save.', 'holmesportfolio-forms' ) . ''; } echo ''; echo ' '; echo ''; echo ''; echo ' '; echo ' ' . esc_html__( 'Cancel', 'holmesportfolio-forms' ) . ''; echo '

'; } public static function is_dark_mode_on() { $user_id = get_current_user_id(); return $user_id ? (bool) get_user_meta( $user_id, self::THEME_META, true ) : false; } /** Logo header with the per-user light/dark toggle. */ public static function brand_header_html() { $is_dark = self::is_dark_mode_on(); $toggle_url = add_query_arg( array( 'action' => 'holmesforms_theme_toggle', '_wpnonce' => wp_create_nonce( 'holmesforms_theme_toggle' ), ), admin_url( 'admin-post.php' ) ); return '' . '' . '' . esc_attr__( 'HolmesPortfolio', 'holmesportfolio-forms' ) . '' . self::new_tab_hint() . '' . '' . ( $is_dark ? '☀ ' . esc_html__( 'Light mode', 'holmesportfolio-forms' ) : '☾ ' . esc_html__( 'Dark mode', 'holmesportfolio-forms' ) ) . '' . ''; } public function handle_theme_toggle() { check_admin_referer( 'holmesforms_theme_toggle' ); $user_id = get_current_user_id(); if ( $user_id ) { if ( get_user_meta( $user_id, self::THEME_META, true ) ) { delete_user_meta( $user_id, self::THEME_META ); } else { update_user_meta( $user_id, self::THEME_META, 1 ); } } $back_url = wp_get_referer(); wp_safe_redirect( $back_url ? $back_url : admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } public function add_theme_body_class( $classes ) { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $screen_id = $screen ? (string) $screen->id : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, picks the screen $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $is_ours = ( 0 === strpos( $screen_id, 'holmesforms' ) ) || ( false !== strpos( $screen_id, self::MENU_SLUG ) ) || ( 0 === strpos( $page, 'holmesforms' ) ); if ( ! $is_ours ) { return $classes; } $classes .= ' hpf-admin-screen'; if ( self::is_dark_mode_on() ) { $classes .= ' hpf-admin-dark'; } return trim( $classes ); } public function handle_entries_request() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, picks the screen if ( ! isset( $_GET['page'] ) || self::ENTRIES_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } if ( ! current_user_can( self::CAP_ENTRIES ) ) { return; } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-entries.php'; ( new HP_Forms_Entries() )->handle_request(); } public static function unread_entries_count() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-storage.php'; $scope = self::scoped_form_ids(); $cache_key = 'holmesforms_ucnt_' . md5( is_array( $scope ) ? 'in:' . implode( ',', $scope ) . '|' . wp_json_encode( apply_filters( 'holmesforms_entry_routing', null ) ) : 'all' ); return (int) HP_Forms_Storage::shared_counts( $cache_key, HP_Forms_Storage::count_generation(), self::UNREAD_COUNT_CACHE_SECONDS, static function () use ( $scope ) { return HP_Forms_Storage::count( array( 'form_ids' => $scope, 'status' => 'unread', ) ); } ); } public function screen_entries() { if ( ! current_user_can( self::CAP_ENTRIES ) ) { return; } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-entries.php'; $view_id = isset( $_GET['view'] ) ? absint( $_GET['view'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which entry to show if ( $view_id ) { HP_Forms_Entries::render_single( $view_id ); return; } echo wp_kses( '
' . self::brand_header_html() . '

' . esc_html__( 'Form entries', 'holmesportfolio-forms' ) . '

', HP_Forms_Kses::admin() ); $tabs = self::sorted_entries_tabs(); $scope = self::scoped_form_ids(); $tabs = self::limit_and_count_entries_tabs( $tabs, $scope ); $has_addon = count( $tabs ) > 1; $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'entries'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which tab shows if ( ! isset( $tabs[ $active_tab ] ) ) { $active_tab = 'entries'; } if ( $has_addon ) { self::render_entries_tab_bar( $tabs, $active_tab ); } if ( (bool) apply_filters( 'holmesforms_entries_screen_takeover', false, $active_tab ) ) { echo '
'; return; } if ( 'entries' !== $active_tab ) { do_action( 'holmesforms_entries_tab_' . $active_tab, $active_tab ); echo ''; return; } self::render_entries_notes( $scope ); $table = new HP_Forms_Entries(); $table->prepare_items(); self::render_export_notice(); if ( class_exists( 'HP_Forms_Crypt' ) && HP_Forms_Crypt::is_active() ) { echo '

' . esc_html__( 'The search box only matches an exact reference or email, not part of one. That keeps it fast, however many entries you have.', 'holmesportfolio-forms' ) . '

'; } $table->views(); echo '
'; echo ''; if ( ! empty( $_GET['status'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, keeps the chosen status in the search form echo ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, keeps the chosen status in the search form } $table->search_box( __( 'Search entries', 'holmesportfolio-forms' ), 'hpf-entry' ); $table->display(); echo '
'; self::print_bulk_delete_confirm_script(); echo ''; } /** @return array[] Entries first, add-on tabs in order, analytics last. */ private static function sorted_entries_tabs() { $tabs = array( 'entries' => array( 'label' => __( 'Entries', 'holmesportfolio-forms' ) ), ); $tabs = (array) apply_filters( 'holmesforms_entries_tabs', $tabs ); $tab_order = (array) apply_filters( 'holmesforms_entries_tab_order', array( 'entries', 'workflow', 'reassign', 'closed', 'spam', 'approvals', 'booking', 'reports', 'analytics' ) ); $sorted_tabs = array(); // Analytics and Issues always close the row, in that order. $last_slugs = array( 'analytics', HP_Forms_Issues::TAB ); foreach ( $tab_order as $slug ) { if ( isset( $tabs[ $slug ] ) && ! in_array( $slug, $last_slugs, true ) ) { $sorted_tabs[ $slug ] = $tabs[ $slug ]; } } foreach ( $tabs as $slug => $tab ) { if ( ! isset( $sorted_tabs[ $slug ] ) && ! in_array( $slug, $last_slugs, true ) ) { $sorted_tabs[ $slug ] = $tab; } } foreach ( $last_slugs as $slug ) { if ( isset( $tabs[ $slug ] ) ) { $sorted_tabs[ $slug ] = $tabs[ $slug ]; } } return $sorted_tabs; } private static function limit_and_count_entries_tabs( array $tabs, $scope ) { if ( is_array( $scope ) && ! $scope ) { $tabs = array_intersect_key( $tabs, array( 'entries' => true ) ); } if ( isset( $tabs['entries'] ) && ! isset( $tabs['entries']['count'] ) ) { $unread = self::unread_entries_count(); $tabs['entries']['count'] = $unread; $tabs['entries']['count_label'] = sprintf( /* translators: %s: number of unread entries */ _n( '%s unread', '%s unread', $unread, 'holmesportfolio-forms' ), number_format_i18n( $unread ) ); } return $tabs; } private static function render_entries_tab_bar( array $tabs, $active_tab ) { echo ''; } /** @return string Count bubble for a tab, or ''. */ private static function tab_count_html( array $tab ) { if ( ! isset( $tab['count'] ) || (int) $tab['count'] <= 0 ) { return ''; } $count = (int) $tab['count']; $screen_reader_text = isset( $tab['count_label'] ) && '' !== $tab['count_label'] ? (string) $tab['count_label'] : sprintf( /* translators: %s: number of items */ _n( '%s item', '%s items', $count, 'holmesportfolio-forms' ), number_format_i18n( $count ) ); return ' ' . '' . esc_html( $screen_reader_text ) . ''; } private static function render_entries_notes( $scope ) { if ( current_user_can( 'holmesforms_admin' ) ) { $settings = get_option( 'holmesforms_settings', array() ); if ( 'never' === ( is_array( $settings ) && isset( $settings['store_mode'] ) ? $settings['store_mode'] : 'all' ) ) { $data_tab_url = add_query_arg( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'data', ), admin_url( 'admin.php' ) ); echo '

' . esc_html__( 'Submissions are not being stored: "Store submissions" is set to "Never save any submissions" on Settings → Data & privacy. This list will stay empty until that changes.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'Open Data & privacy settings', 'holmesportfolio-forms' ) . '

'; } } if ( is_array( $scope ) && ! $scope ) { echo '

' . esc_html__( 'No forms are shared with you yet. Ask an admin if you need to see entries.', 'holmesportfolio-forms' ) . '

'; } echo '

' . esc_html__( 'Guide: Entries', 'holmesportfolio-forms' ) . ' ' . esc_html__( '(opens in new tab)', 'holmesportfolio-forms' ) . '

'; } private static function render_export_notice() { $notice = isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect if ( 'export_too_big' === $notice ) { $count = isset( $_GET['n'] ) ? absint( $_GET['n'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect echo '

' . esc_html( sprintf( /* translators: 1: selected row count, 2: the export limit */ __( 'That selection is %1$d entries, more than the %2$d this can export at once. Narrow the filter, or pick fewer forms, and export in batches.', 'holmesportfolio-forms' ), $count, HP_Forms_Entries::MAX_EXPORT_ROWS ) ) . '

'; } elseif ( 'export_empty' === $notice ) { echo '

' . esc_html__( 'Nothing to export for that selection.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'export_none_selected' === $notice ) { echo '

' . esc_html__( 'Nothing was exported. Tick the entries you want first.', 'holmesportfolio-forms' ) . '

'; } } private static function print_bulk_delete_confirm_script() { self::enqueue_screen_scripts(); echo ''; } public function screen_settings() { if ( ! current_user_can( apply_filters( 'holmesforms_settings_menu_cap', self::CAP ) ) ) { return; } $tabs = self::settings_tabs(); $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which tab shows if ( ! isset( $tabs[ $active_tab ] ) ) { $active_tab = 'overview'; } echo wp_kses( '
' . self::brand_header_html() . '

' . esc_html__( 'HolmesPortfolio Forms: Settings', 'holmesportfolio-forms' ) . '

', HP_Forms_Kses::admin() ); if ( class_exists( 'HP_Forms_Tour' ) ) { echo wp_kses( HP_Forms_Tour::add_on_buttons_html(), HP_Forms_Kses::admin() ); } echo '
'; echo ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect if ( 'data' === $active_tab && isset( $_GET['holmesforms_enc'] ) ) { $encrypted_count = absint( $_GET['holmesforms_enc'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- the plugin's own table, counts unsealed rows $remaining = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE data NOT LIKE %s AND data <> ''", HP_Forms_Storage::table(), $wpdb->esc_like( 'hpfenc1:' ) . '%' ) ); $message = $remaining > 0 ? sprintf( /* translators: 1: number encrypted this run, 2: number still left */ _n( 'Encrypted %1$s entry. %2$s still to go. Click the button again.', 'Encrypted %1$s entries. %2$s still to go. Click the button again.', $encrypted_count, 'holmesportfolio-forms' ), number_format_i18n( $encrypted_count ), number_format_i18n( $remaining ) ) : sprintf( /* translators: %s: number encrypted this run */ _n( 'Encrypted %s entry. Every stored entry is now encrypted.', 'Encrypted %s entries. Every stored entry is now encrypted.', $encrypted_count, 'holmesportfolio-forms' ), number_format_i18n( $encrypted_count ) ); echo '

' . esc_html( $message ) . '

'; } if ( 'overview' === $active_tab ) { $this->render_settings_overview( $tabs ); } elseif ( ! empty( $tabs[ $active_tab ]['render'] ) && is_callable( $tabs[ $active_tab ]['render'] ) ) { call_user_func( $tabs[ $active_tab ]['render'] ); } elseif ( 'tools' === $active_tab ) { $this->render_tools_body(); } elseif ( 'help' === $active_tab ) { $this->render_settings_sections( self::SETTINGS_SLUG . '-help' ); $this->render_setup_wizard_card(); $this->render_lead_capture_card(); } else { if ( 'data' === $active_tab ) { do_action( 'holmesforms_before_data_settings' ); } settings_errors( self::SETTINGS ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WordPress's own flag after saving; nothing is changed. if ( isset( $_GET['settings-updated'] ) && 'true' === $_GET['settings-updated'] ) { echo '

' . esc_html__( 'Settings saved.', 'holmesportfolio-forms' ) . '

'; } echo '
'; settings_fields( 'holmesforms_settings_group' ); printf( '', esc_attr( self::SETTINGS ), esc_attr( $active_tab ) ); $this->render_settings_sections( self::SETTINGS_SLUG . '-' . $active_tab ); submit_button(); echo '
'; if ( 'general' === $active_tab ) { $this->render_starter_content(); } if ( 'email' === $active_tab ) { $this->render_email_test(); } } echo '
'; } private function render_settings_sections( $page ) { global $wp_settings_sections, $wp_settings_fields; if ( empty( $wp_settings_sections[ $page ] ) ) { return; } foreach ( (array) $wp_settings_sections[ $page ] as $section ) { echo '
'; if ( $section['title'] ) { echo '

' . esc_html( $section['title'] ) . '

'; } if ( $section['callback'] && '__return_false' !== $section['callback'] ) { echo '
'; call_user_func( $section['callback'], $section ); echo '
'; } if ( ! empty( $wp_settings_fields[ $page ][ $section['id'] ] ) ) { echo ''; do_settings_fields( $page, $section['id'] ); echo ''; } echo '
'; } } private function render_settings_overview( array $tabs ) { $groups = self::settings_hub_groups(); $tabs_by_group = array_fill_keys( array_keys( $groups ), array() ); foreach ( $tabs as $slug => $tab ) { if ( 'overview' === $slug ) { continue; } $group = isset( $tab['group'] ) && isset( $tabs_by_group[ $tab['group'] ] ) ? $tab['group'] : 'help'; $tabs_by_group[ $group ][ $slug ] = $tab; } echo '

' . esc_html__( 'Everything that configures the plugin, grouped. Pick a card, or use the tabs above.', 'holmesportfolio-forms' ) . '

'; do_action( 'holmesforms_settings_overview_top', $tabs ); $i = 0; foreach ( $groups as $group_key => $group_name ) { if ( empty( $tabs_by_group[ $group_key ] ) ) { continue; } echo '
'; echo '

' . esc_html( $group_name ) . '

'; echo '
'; foreach ( $tabs_by_group[ $group_key ] as $slug => $tab ) { $badge = ! empty( $tab['badge'] ) ? ' ' . esc_html( $tab['badge'] ) . '' : ''; printf( '%s%s%s', esc_url( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG . '&tab=' . $slug ) ), esc_html( isset( $tab['label'] ) ? $tab['label'] : $slug ), wp_kses( $badge, HP_Forms_Kses::admin() ), esc_html( isset( $tab['desc'] ) ? $tab['desc'] : '' ) ); } echo '
'; echo '
'; ++$i; } } private function render_starter_content() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect if ( 'restored' === ( isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : '' ) ) { $added = isset( $_GET['a'] ) ? absint( $_GET['a'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect $reset = isset( $_GET['o'] ) ? absint( $_GET['o'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect $skipped_locked = isset( $_GET['l'] ) ? absint( $_GET['l'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect $is_blocked = ! empty( $_GET['blocked'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect $has_backup = ! empty( $_GET['b'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect if ( $is_blocked ) { $message = __( 'Nothing was changed. Tick the confirmation box to overwrite what is already there.', 'holmesportfolio-forms' ); } else { $parts = array(); if ( $added ) { /* translators: %d: count */ $parts[] = sprintf( _n( '%d added', '%d added', $added, 'holmesportfolio-forms' ), $added ); } if ( $reset ) { /* translators: %d: count */ $parts[] = sprintf( _n( '%d reset', '%d reset', $reset, 'holmesportfolio-forms' ), $reset ); } if ( $skipped_locked ) { /* translators: %d: count */ $parts[] = sprintf( _n( '%d skipped (locked)', '%d skipped (locked)', $skipped_locked, 'holmesportfolio-forms' ), $skipped_locked ); } $message = $parts ? ucfirst( implode( ', ', $parts ) ) . '.' : __( 'Nothing needed restoring.', 'holmesportfolio-forms' ); if ( $has_backup ) { $message .= ' ' . __( 'A backup was saved first. It is under Automatic backups on the Backup and import tab.', 'holmesportfolio-forms' ); } } printf( '

%s

', $is_blocked ? 'warning' : 'success', esc_html( $message ) ); } echo '

' . esc_html__( 'Restore starter content', 'holmesportfolio-forms' ) . '

'; echo '

' . esc_html__( 'The starter blocks and forms are built into the plugin, so you can restore them at any time. A reset skips anything you have locked.', 'holmesportfolio-forms' ) . '

'; $this->render_starter_restore_row( __( 'Starter blocks', 'holmesportfolio-forms' ), HP_Forms_Presets::restore_preview(), 'holmesforms_restore_blocks', __( 'a locked block', 'holmesportfolio-forms' ) ); self::render_starter_picker( true ); echo '
'; } private function render_setup_wizard_card() { if ( ! class_exists( 'HP_Forms_Setup' ) || ! current_user_can( 'holmesforms_admin' ) ) { return; } echo '

' . esc_html__( 'Setup wizard', 'holmesportfolio-forms' ) . '

'; echo '

' . esc_html__( 'A short guided walk through the main choices: look, privacy, email, spam and starter forms. Skipped it, or want to check your choices again? Run it here. Everything in it can also be changed on the Settings tabs.', 'holmesportfolio-forms' ) . '

'; echo '

' . esc_html__( 'Run the setup wizard', 'holmesportfolio-forms' ) . '

'; } private function render_lead_capture_card() { if ( ! apply_filters( 'holmesforms_lead_capture_enabled', false ) ) { return; } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-leads.php'; $result = isset( $_GET['holmesforms_lead'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_lead'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a result flag from the redirect $notices = array( 'sent' => array( 'success', __( 'Thanks. Check your inbox and click the link to confirm.', 'holmesportfolio-forms' ) ), 'queued' => array( 'warning', __( 'HolmesPortfolio’s server was busy. Your sign-up is saved here. We’ll keep trying to send it for about a day. The confirmation email arrives once it goes through.', 'holmesportfolio-forms' ) ), 'updated' => array( 'success', __( 'Your sign-up is updated.', 'holmesportfolio-forms' ) ), 'join_sent' => array( 'success', __( 'Check the inbox you signed up with. Open the link in our email and confirm to join the mailing list.', 'holmesportfolio-forms' ) ), 'join_wait' => array( 'warning', __( 'We’ve already emailed a link to that address today. Check your inbox, or try again tomorrow.', 'holmesportfolio-forms' ) ), 'removed' => array( 'success', __( 'Your sign-up is removed. HolmesPortfolio has deleted your details from its sign-up list.', 'holmesportfolio-forms' ) ), 'failed' => array( 'error', __( 'That didn’t work. Try again in a few minutes.', 'holmesportfolio-forms' ) ), ); if ( isset( $notices[ $result ] ) ) { echo '

' . esc_html( $notices[ $result ][1] ) . '

'; } echo '

' . esc_html__( 'Sign up with HolmesPortfolio', 'holmesportfolio-forms' ) . '

'; echo '
'; wp_nonce_field( 'holmesforms_lead_opt_in' ); echo ''; $user = wp_get_current_user(); $state = HP_Forms_Leads::render_fields( $user instanceof WP_User ? $user->user_email : '' ); if ( 'new' === $state ) { submit_button( __( 'Sign up', 'holmesportfolio-forms' ) ); } elseif ( 'queue_failed' === $state || ( 'pending' === $state && 0 === HP_Forms_Leads::minutes_until_resend() ) ) { submit_button( __( 'Send again', 'holmesportfolio-forms' ), 'primary', 'lead_send_again' ); } echo '
'; if ( 'signed_up' === $state ) { $lead_state = HP_Forms_Leads::sign_up_state(); $print_manage_button = static function ( $action, $label, $button_class, $confirm_text = '' ) { echo '
'; wp_nonce_field( 'holmesforms_lead_manage' ); echo ''; echo '
'; }; if ( $lead_state['mailing'] ) { $print_manage_button( 'leave', __( 'Leave the mailing list', 'holmesportfolio-forms' ), 'button-secondary' ); } else { $print_manage_button( 'join', __( 'Join the mailing list', 'holmesportfolio-forms' ), 'button-secondary' ); } $print_manage_button( 'delete', __( 'Remove my sign-up', 'holmesportfolio-forms' ), 'button-secondary', __( 'Remove your sign-up? HolmesPortfolio deletes your sign-up details and you leave the mailing list.', 'holmesportfolio-forms' ) ); } echo '
'; } public function handle_lead_manage() { check_admin_referer( 'holmesforms_lead_manage' ); if ( ! current_user_can( 'holmesforms_admin' ) ) { wp_die( esc_html__( 'Not allowed.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-leads.php'; $action = isset( $_POST['do'] ) ? sanitize_key( wp_unslash( $_POST['do'] ) ) : ''; $result = HP_Forms_Leads::manage_sign_up( $action ); if ( ! $result ) { $outcome = 'failed'; } elseif ( 'delete' === $action ) { $outcome = 'removed'; } elseif ( ! empty( $result['join_sent'] ) ) { $outcome = 'join_sent'; } elseif ( ! empty( $result['join_wait'] ) ) { $outcome = 'join_wait'; } else { $outcome = 'updated'; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG . '&tab=help&holmesforms_lead=' . $outcome ) ); exit; } public function handle_lead_opt_in() { check_admin_referer( 'holmesforms_lead_opt_in' ); if ( ! current_user_can( 'holmesforms_admin' ) ) { wp_die( esc_html__( 'Not allowed.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-leads.php'; HP_Forms_Leads::handle_submit( array( 'lead_email' => isset( $_POST['lead_email'] ) ? sanitize_email( wp_unslash( $_POST['lead_email'] ) ) : '', 'lead_business_type' => isset( $_POST['lead_business_type'] ) ? sanitize_key( wp_unslash( $_POST['lead_business_type'] ) ) : '', 'lead_consent' => ! empty( $_POST['lead_consent'] ), 'lead_mailing' => ! empty( $_POST['lead_mailing'] ), 'lead_resend' => ! empty( $_POST['lead_resend'] ), 'lead_send_again' => ! empty( $_POST['lead_send_again'] ), ) ); $notice_arg = '' !== HP_Forms_Leads::queue_state() ? '&holmesforms_lead=queued' : ( HP_Forms_Leads::is_pending() ? '&holmesforms_lead=sent' : '' ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG . '&tab=help' . $notice_arg ) ); exit; } /** @param bool $with_form True on Settings, false inside the wizard's own form. */ public static function render_starter_picker( $with_form = true ) { $rows = HP_Forms_Plugin::starter_forms_status(); $state_labels = array( 'missing' => __( 'Missing', 'holmesportfolio-forms' ), 'unchanged' => __( 'Unchanged', 'holmesportfolio-forms' ), 'older' => __( 'Older version', 'holmesportfolio-forms' ), 'changed' => __( 'Changed by you', 'holmesportfolio-forms' ), 'locked' => __( 'Open in the builder', 'holmesportfolio-forms' ), ); $groups = array(); $with_entries = array(); foreach ( $rows as $row ) { $groups[ $row['group'] ][] = $row; if ( $row['entries'] && 'missing' !== $row['state'] ) { $with_entries[] = sprintf( '%s (%d)', $row['title'], $row['entries'] ); } } if ( $with_form ) { echo '

' . esc_html__( 'Starter forms', 'holmesportfolio-forms' ) . '

'; echo '

' . esc_html__( 'Tick the forms you want to add. Ticking one that is already here resets it to the built-in version.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'Each form is an example, written in good faith. It is not legal advice. Check it and set your own before you publish.', 'holmesportfolio-forms' ) . '

'; echo '
'; echo ''; wp_nonce_field( 'holmesforms_restore_forms' ); } echo '
'; echo ''; foreach ( $groups as $group => $group_rows ) { $group_id = 'hpf-grp-' . sanitize_key( $group ); $form_count = count( $group_rows ); echo ''; echo ''; foreach ( $group_rows as $row ) { $is_locked = 'locked' === $row['state']; $pick_id = 'hpf-pick-' . sanitize_key( $row['title'] ); $suits_html = ''; if ( '' !== $row['suits'] ) { /* translators: %s: the kinds of organisation a starter form suits */ $suits_html = '' . esc_html( sprintf( __( 'Suits: %s', 'holmesportfolio-forms' ), $row['suits'] ) ) . ''; } echo ''; echo wp_kses( '', HP_Forms_Kses::admin() ); echo ''; echo ''; } echo ''; } echo '
' . esc_html__( 'Select', 'holmesportfolio-forms' ) . '' . esc_html__( 'Form', 'holmesportfolio-forms' ) . '' . esc_html__( 'Status', 'holmesportfolio-forms' ) . '' . esc_html__( 'Entries', 'holmesportfolio-forms' ) . '
' . $suits_html . '' . esc_html( $state_labels[ $row['state'] ] ) . '' . ( 'missing' === $row['state'] ? '–' : (int) $row['entries'] ) . '
'; if ( $with_entries ) { echo '

' . esc_html__( 'Has entries:', 'holmesportfolio-forms' ) . ' ' . esc_html( implode( ', ', $with_entries ) ) . '. ' . esc_html__( 'A reset keeps the entries but puts the questions back to the built-in version.', 'holmesportfolio-forms' ) . '

'; } echo ''; echo '
'; if ( $with_form ) { submit_button( __( 'Restore ticked forms', 'holmesportfolio-forms' ), 'secondary', 'submit', false ); echo '
'; } } /** * @param array $preview { add, overwrite, locked } name lists. * @param string $action admin-post action, also the nonce name. */ private function render_starter_restore_row( $heading, array $preview, $action, $locked_why ) { $to_add = $preview['add']; $to_overwrite = $preview['overwrite']; $locked = $preview['locked']; echo '

' . esc_html( $heading ) . '

'; $lines = array(); if ( $to_add ) { $lines[] = '' . esc_html__( 'Will add:', 'holmesportfolio-forms' ) . ' ' . esc_html( implode( ', ', $to_add ) ) . '.'; } if ( $to_overwrite ) { $lines[] = '' . esc_html__( 'Will overwrite:', 'holmesportfolio-forms' ) . ' ' . esc_html__( '(needs the box below ticked):', 'holmesportfolio-forms' ) . ' ' . esc_html( implode( ', ', $to_overwrite ) ) . '.'; } if ( $locked ) { $lines[] = esc_html( sprintf( /* translators: 1: comma list of names, 2: "a locked block" / "a form open in the builder" */ __( '%1$s is %2$s, so restore will skip it. Unlock it first if you want it reset.', 'holmesportfolio-forms' ), implode( ', ', $locked ), $locked_why ) ); } if ( ! $lines ) { $lines[] = esc_html__( 'Everything is present and unchanged. There is nothing to do.', 'holmesportfolio-forms' ); } foreach ( $lines as $line ) { echo '

' . wp_kses( $line, array( 'strong' => array() ) ) . '

'; } echo '
'; echo ''; wp_nonce_field( $action ); if ( $to_overwrite ) { echo ''; } $label = ( $to_add || $to_overwrite ) ? __( 'Restore', 'holmesportfolio-forms' ) : __( 'Restore anyway', 'holmesportfolio-forms' ); submit_button( $label, 'secondary', 'submit', false ); echo '
'; } /* Import / Export */ public function render_tools_body() { $notice = isset( $_GET['holmesforms_notice'] ) ? sanitize_key( wp_unslash( $_GET['holmesforms_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, a notice flag from the redirect $count = isset( $_GET['n'] ) ? absint( $_GET['n'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect if ( 'imported' === $notice ) { $replaced = isset( $_GET['r'] ) ? absint( $_GET['r'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect echo '

' . sprintf( /* translators: %d: number of forms imported */ esc_html( _n( 'Imported %d form.', 'Imported %d forms.', $count, 'holmesportfolio-forms' ) ), (int) $count ); if ( $replaced ) { echo ' ' . esc_html( sprintf( /* translators: %d: number of forms replaced */ _n( '%d matching form was replaced.', '%d matching forms were replaced.', $replaced, 'holmesportfolio-forms' ), (int) $replaced ) ); } $skipped = isset( $_GET['s'] ) ? absint( $_GET['s'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect if ( $skipped ) { echo ' ' . esc_html( sprintf( /* translators: %d: number of forms skipped because they are already here */ _n( '%d form was skipped because it is already here. Tick "Replace forms" to overwrite it.', '%d forms were skipped because they are already here. Tick "Replace forms" to overwrite them.', $skipped, 'holmesportfolio-forms' ), (int) $skipped ) ); } if ( isset( $_GET['capped'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect echo ' ' . esc_html( sprintf( /* translators: %d: maximum forms per import */ __( 'The file held more than %d forms. The rest were skipped.', 'holmesportfolio-forms' ), self::MAX_IMPORT_FORMS ) ); } echo '

'; $settings_status = isset( $_GET['st'] ) ? absint( $_GET['st'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, counts from the redirect if ( $settings_status ) { echo '
'; if ( 2 === $settings_status ) { echo '

' . esc_html__( 'Some secret keys for the spam check couldn\'t be opened with this site\'s encryption key, so they\'re now blank.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'Enter them again on the Spam tab.', 'holmesportfolio-forms' ) . '

'; } echo '

' . esc_html__( 'Encrypted entries only open with the key they were saved with. If your entries came from another site, wp-config.php here needs the same HOLMESFORMS_ENCRYPTION_KEY line as that site.', 'holmesportfolio-forms' ) . '

'; echo '
'; } } elseif ( 'import_too_big' === $notice ) { echo '

' . sprintf( /* translators: %s: server upload limit, e.g. "8 MB" */ esc_html__( 'That file is larger than this server accepts for uploads (%s). Export fewer forms, or raise the upload limit.', 'holmesportfolio-forms' ), esc_html( size_format( wp_max_upload_size() ) ) ) . '

'; } elseif ( 'import_error' === $notice ) { echo '

' . esc_html__( 'That file could not be read as a HolmesPortfolio Forms export.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'backup_done' === $notice ) { echo '

' . esc_html__( 'Backup written.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'backup_failed' === $notice ) { echo '

' . esc_html__( 'The backup could not be written. Check that the uploads folder is writable.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'backup_on' === $notice ) { echo '

' . esc_html__( 'Weekly backups are on.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'backup_off' === $notice ) { echo '

' . esc_html__( 'Weekly backups are off. Files already saved are kept.', 'holmesportfolio-forms' ) . '

'; } elseif ( 'backup_deleted' === $notice ) { echo '

' . esc_html__( 'Backup file deleted.', 'holmesportfolio-forms' ) . '

'; } echo '

' . esc_html__( 'Back up your forms and settings from here, or bring a backup or a single form back in. To export one form, use Export on the Forms list. Files hold forms and settings only, with no submissions, entries, uploaded files or drafts. They hold email addresses you entered, so keep them private. Secret keys are locked or left out, so after a restore you may need to enter some of them again.', 'holmesportfolio-forms' ) . '

'; do_action( 'holmesforms_after_tools_body' ); echo '

' . esc_html__( 'Import', 'holmesportfolio-forms' ) . '

'; echo '

' . esc_html( sprintf( /* translators: 1: max forms per file, 2: server upload limit */ __( 'Upload a backup file or a form you exported, from this or another site. By default forms are added as new and existing ones are left alone. Up to %1$d forms and %2$s per file.', 'holmesportfolio-forms' ), self::MAX_IMPORT_FORMS, size_format( wp_max_upload_size() ) ) ) . '

'; echo '
'; echo ''; echo ''; wp_nonce_field( 'holmesforms_import_config' ); echo '

'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '

'; submit_button( __( 'Import selected forms', 'holmesportfolio-forms' ), 'primary', 'submit', false, array( 'id' => 'hpf-import-submit' ) ); echo '

'; } /** @param int[]|null $form_ids Null for all forms. */ public static function build_export_payload( $form_ids = null, $include_settings = true, $include_presets = true ) { $args = array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, 'orderby' => 'ID', 'order' => 'ASC', ); if ( is_array( $form_ids ) && $form_ids ) { $args['post__in'] = array_map( 'absint', $form_ids ); } $admin = self::instance(); $forms = array(); foreach ( get_posts( $args ) as $post ) { $forms[] = $admin->collect_form_export( $post ); } $payload = array( '_type' => 'holmesportfolio-forms-export', 'version' => HOLMESFORMS_VERSION, 'exported' => gmdate( 'c' ), 'forms' => $forms, ); if ( $include_settings ) { $payload['settings'] = get_option( self::SETTINGS, array() ); require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; foreach ( array( 'turnstile_secret', 'recaptcha_secret' ) as $secret_key ) { if ( ! empty( $payload['settings'][ $secret_key ] ) && ! HP_Forms_Crypt::is_sealed( $payload['settings'][ $secret_key ] ) ) { $payload['settings'][ $secret_key ] = HP_Forms_Crypt::encrypt( $payload['settings'][ $secret_key ] ); } if ( ! empty( $payload['settings'][ $secret_key ] ) && ! HP_Forms_Crypt::is_sealed( $payload['settings'][ $secret_key ] ) ) { $payload['settings'][ $secret_key ] = ''; $payload['secrets_note'] = __( 'Spam check secret keys are not exported. Re-enter them after import.', 'holmesportfolio-forms' ); } } $addon_settings = apply_filters( 'holmesforms_export_settings', array() ); if ( is_array( $addon_settings ) && $addon_settings ) { $payload['settings_ext'] = $addon_settings; } } if ( $include_presets ) { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; $payload['presets'] = HP_Forms_Presets::export_bundle(); } return $payload; } private function collect_form_export( WP_Post $post ) { $pages = get_post_meta( $post->ID, HP_Forms_Form::META_PAGES, true ); $variables = get_post_meta( $post->ID, HP_Forms_Form::META_VARIABLES, true ); $group = wp_get_object_terms( $post->ID, HOLMESFORMS_FORM_GROUP_TAX, array( 'fields' => 'names' ) ); $group = ( ! is_wp_error( $group ) && $group ) ? html_entity_decode( (string) $group[0], ENT_QUOTES ) : ''; return array( 'title' => $post->post_title, 'export_key' => HP_Forms_Form::ensure_export_key( $post->ID ), 'status' => in_array( $post->post_status, array( 'publish', 'draft' ), true ) ? $post->post_status : 'draft', 'group' => $group, 'pages' => is_array( $pages ) ? $pages : array(), 'variables' => is_array( $variables ) ? $variables : array(), 'debug' => (bool) get_post_meta( $post->ID, HP_Forms_Form::META_DEBUG, true ), 'group_inbox' => (string) get_post_meta( $post->ID, HP_Forms_Form::META_GROUP_INBOX, true ), 'entry_label' => self::sanitize_entry_label( get_post_meta( $post->ID, HP_Forms_Form::META_ENTRY_LABEL, true ) ), 'ref_prefix' => (string) get_post_meta( $post->ID, HP_Forms_Form::META_REF_PREFIX, true ), 'starter' => (string) get_post_meta( $post->ID, HP_Forms_Plugin::STARTER_PRINT_META, true ), 'bh_region' => HP_Forms_Form::clean_bank_holiday_region( get_post_meta( $post->ID, HP_Forms_Form::META_BH_REGION, true ) ), 'case_view' => (string) HP_Forms_Form::get( $post->ID )->case_view(), /** Add-on settings shown on the builder Setup tab. */ 'ext' => (array) apply_filters( 'holmesforms_builder_form_ext', array(), $post->ID ), 'addon' => (array) apply_filters( 'holmesforms_export_form_addon', array(), $post->ID ), ); } public function handle_export_form() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_export_form_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $post = get_post( $form_id ); if ( ! $post || HOLMESFORMS_POST_TYPE !== $post->post_type ) { wp_die( esc_html__( 'Form not found.', 'holmesportfolio-forms' ), '', array( 'response' => 404 ) ); } $payload = array( '_type' => 'holmesportfolio-forms-export', 'version' => HOLMESFORMS_VERSION, 'exported' => gmdate( 'c' ), 'forms' => array( $this->collect_form_export( $post ) ), ); $slug = sanitize_title( $post->post_title ); $filename = 'form-' . ( '' !== $slug ? $slug : $form_id ) . '-' . gmdate( 'Ymd-His' ) . '.json'; nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); echo wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } /** @return string[] Group names at least one form uses now, A to Z. */ private static function used_group_names() { $ids = get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true, ) ); if ( ! $ids ) { return array(); } $names = wp_get_object_terms( $ids, HOLMESFORMS_FORM_GROUP_TAX, array( 'fields' => 'names' ) ); $names = is_wp_error( $names ) ? array() : array_values( array_unique( array_map( 'strval', $names ) ) ); natcasesort( $names ); return array_values( $names ); } private static function render_add_to_page_link( $form_id, $form_title = '' ) { if ( ! current_user_can( 'edit_pages' ) ) { return; } $url = wp_nonce_url( admin_url( 'admin-post.php?action=holmesforms_add_to_page&form=' . (int) $form_id ), 'holmesforms_add_to_page_' . (int) $form_id ); /* translators: %s: form name */ $link_name = '' !== $form_title ? sprintf( __( 'Add to a new page, “%s”', 'holmesportfolio-forms' ), $form_title ) : __( 'Add to a new page', 'holmesportfolio-forms' ); echo ' ' . esc_html__( 'Add to a new page', 'holmesportfolio-forms' ) . ''; } public function handle_add_to_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) || ! current_user_can( 'edit_pages' ) || ! check_admin_referer( 'holmesforms_add_to_page_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $post = get_post( $form_id ); if ( ! $post || HOLMESFORMS_POST_TYPE !== $post->post_type ) { wp_die( esc_html__( 'Form not found.', 'holmesportfolio-forms' ), '', array( 'response' => 404 ) ); } $page_id = wp_insert_post( array( 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => $post->post_title, 'post_content' => "\n[holmesforms_form id=\"" . $form_id . "\"]\n", ), true ); if ( is_wp_error( $page_id ) ) { wp_die( esc_html( $page_id->get_error_message() ) ); } wp_safe_redirect( get_edit_post_link( $page_id, 'raw' ) ); exit; } public function handle_duplicate_form() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, which form the screen shows $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_duplicate_form_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $post = get_post( $form_id ); if ( ! $post || HOLMESFORMS_POST_TYPE !== $post->post_type ) { wp_die( esc_html__( 'Form not found.', 'holmesportfolio-forms' ), '', array( 'response' => 404 ) ); } $form = $this->collect_form_export( $post ); $form['title'] = $this->unique_copy_title( $post->post_title ); unset( $form['export_key'] ); /* translators: %s: title of the form that was copied */ $copy_id = $this->import_one_form( $form, 0, sprintf( __( 'Copied from %s', 'holmesportfolio-forms' ), $post->post_title ) ); if ( is_wp_error( $copy_id ) ) { wp_die( esc_html( $copy_id->get_error_message() ) ); } $settings = get_option( self::SETTINGS, array() ); if ( is_array( $settings ) ) { $has_changed = false; foreach ( array( 'store_forms', 'words_forms', 'retention_overrides' ) as $setting_key ) { if ( isset( $settings[ $setting_key ][ $form_id ] ) ) { $settings[ $setting_key ][ $copy_id ] = $settings[ $setting_key ][ $form_id ]; $has_changed = true; } } if ( $has_changed ) { update_option( self::SETTINGS, $settings ); } } wp_safe_redirect( add_query_arg( array( 'page' => self::BUILD_SLUG, 'form' => (int) $copy_id, 'holmesforms_notice' => 'copied', ), admin_url( 'admin.php' ) ) ); exit; } /** @return string "Title (copy)", then "(copy 2)" and on, never a title already used. */ private function unique_copy_title( $title ) { $base_title = trim( (string) preg_replace( '/\s*\(copy(?: \d+)?\)$/', '', (string) $title ) ); /* translators: %s: form title */ $candidate = sprintf( __( '%s (copy)', 'holmesportfolio-forms' ), $base_title ); $copy_number = 2; while ( $this->find_form_id_by_title( $candidate ) && $copy_number < self::MAX_COPY_NUMBER ) { /* translators: 1: form title, 2: copy number */ $candidate = sprintf( __( '%1$s (copy %2$d)', 'holmesportfolio-forms' ), $base_title, $copy_number++ ); } return $candidate; } /** * @param int $overwrite_id Form to overwrite, or 0 to add. * @param string $note History note; defaults to "Form imported". * @return int|WP_Error */ private function import_one_form( array $form, $overwrite_id = 0, $note = '' ) { $title = isset( $form['title'] ) ? sanitize_text_field( $form['title'] ) : __( 'Imported form', 'holmesportfolio-forms' ); $status = ( isset( $form['status'] ) && 'publish' === $form['status'] ) ? 'publish' : 'draft'; $form_id = $overwrite_id ? wp_update_post( array( 'ID' => (int) $overwrite_id, 'post_title' => $title, 'post_status' => $status, ), true ) : wp_insert_post( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => $status, 'post_title' => $title, ), true ); if ( is_wp_error( $form_id ) || ! $form_id ) { return is_wp_error( $form_id ) ? $form_id : new WP_Error( 'holmesforms_import', 'insert failed' ); } $form_id = (int) $form_id; if ( ! empty( $form['export_key'] ) && is_string( $form['export_key'] ) ) { update_post_meta( $form_id, HP_Forms_Form::META_EXPORT_KEY, sanitize_key( $form['export_key'] ) ); } list( $imported_pages, $imported_variables ) = HP_Forms_Form::with_unique_keys( self::sanitize_pages_def( isset( $form['pages'] ) ? $form['pages'] : array() ), self::sanitize_variables_def( isset( $form['variables'] ) ? $form['variables'] : array() ) ); update_post_meta( $form_id, HP_Forms_Form::META_PAGES, wp_slash( $imported_pages ) ); update_post_meta( $form_id, HP_Forms_Form::META_VARIABLES, wp_slash( $imported_variables ) ); delete_post_meta( $form_id, HP_Forms_Form::META_DEBUG ); if ( ! empty( $form['group_inbox'] ) && is_email( $form['group_inbox'] ) ) { update_post_meta( $form_id, HP_Forms_Form::META_GROUP_INBOX, sanitize_email( $form['group_inbox'] ) ); } $label_keys = self::sanitize_entry_label( isset( $form['entry_label'] ) ? $form['entry_label'] : array() ); if ( $label_keys ) { update_post_meta( $form_id, HP_Forms_Form::META_ENTRY_LABEL, $label_keys ); } else { delete_post_meta( $form_id, HP_Forms_Form::META_ENTRY_LABEL ); } if ( ! empty( $form['ref_prefix'] ) ) { update_post_meta( $form_id, HP_Forms_Form::META_REF_PREFIX, wp_slash( sanitize_text_field( $form['ref_prefix'] ) ) ); } if ( isset( $form['starter'] ) && preg_match( '/^[a-f0-9]{32}$/', (string) $form['starter'] ) ) { update_post_meta( $form_id, HP_Forms_Plugin::STARTER_PRINT_META, wp_slash( (string) $form['starter'] ) ); } if ( '' !== HP_Forms_Form::clean_bank_holiday_region( $form['bh_region'] ?? '' ) ) { update_post_meta( $form_id, HP_Forms_Form::META_BH_REGION, HP_Forms_Form::clean_bank_holiday_region( $form['bh_region'] ) ); } if ( isset( $form['group'] ) ) { $group = trim( preg_replace( '/\s+/', ' ', sanitize_text_field( (string) $form['group'] ) ) ); wp_set_object_terms( $form_id, '' !== $group ? array( $group ) : array(), HOLMESFORMS_FORM_GROUP_TAX, false ); } if ( ! empty( $form['case_view'] ) && is_string( $form['case_view'] ) ) { update_post_meta( $form_id, HP_Forms_Form::META_CASE_VIEW, wp_slash( HP_Forms_Components::kses_keeping_if_blocks( $form['case_view'] ) ) ); } if ( isset( $form['ext'] ) && is_array( $form['ext'] ) ) { do_action( 'holmesforms_save_form_ext', $form_id, $form['ext'] ); } if ( isset( $form['addon'] ) && is_array( $form['addon'] ) ) { do_action( 'holmesforms_import_form_addon', $form_id, $form['addon'] ); } $this->snapshot_revision( $form_id, $this->export_definition( $form_id ), '' !== $note ? $note : __( 'Form imported', 'holmesportfolio-forms' ) ); return $form_id; } private function find_form_id_by_title( $title ) { $title = strtolower( trim( (string) $title ) ); if ( '' === $title ) { return 0; } foreach ( get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => array( 'publish', 'draft' ), 'numberposts' => -1, 'no_found_rows' => true, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', ) ) as $form_id ) { if ( strtolower( trim( (string) get_the_title( $form_id ) ) ) === $title ) { return (int) $form_id; } } return 0; } private function is_identical_to_import( $form_id, array $form ) { $pages = get_post_meta( $form_id, HP_Forms_Form::META_PAGES, true ); $variables = get_post_meta( $form_id, HP_Forms_Form::META_VARIABLES, true ); return wp_json_encode( self::sanitize_pages_def( is_array( $pages ) ? $pages : array() ) ) === wp_json_encode( self::sanitize_pages_def( isset( $form['pages'] ) && is_array( $form['pages'] ) ? $form['pages'] : array() ) ) && wp_json_encode( self::sanitize_variables_def( $variables ) ) === wp_json_encode( self::sanitize_variables_def( isset( $form['variables'] ) ? $form['variables'] : array() ) ); } private function redirect_after_form_import( $notice, array $extra_args = array() ) { wp_safe_redirect( add_query_arg( array_merge( array( 'page' => self::MENU_SLUG, 'holmesforms_notice' => $notice, ), $extra_args ), admin_url( 'admin.php' ) ) ); exit; } public function handle_import_form() { if ( ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_import_form' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $import = self::read_uploaded_json( 'file', 'holmesforms_import_form' ); $form = null; if ( is_array( $import ) && isset( $import['forms'] ) && is_array( $import['forms'] ) ) { foreach ( $import['forms'] as $candidate ) { if ( is_array( $candidate ) && isset( $candidate['pages'] ) && is_array( $candidate['pages'] ) ) { $form = $candidate; break; } } } if ( ! $form ) { $this->redirect_after_form_import( 'import_error' ); } $existing_id = ! empty( $form['export_key'] ) ? HP_Forms_Form::find_id_by_export_key( $form['export_key'] ) : 0; $import_title = strtolower( trim( isset( $form['title'] ) ? (string) $form['title'] : '' ) ); if ( $existing_id && '' !== $import_title && strtolower( trim( get_the_title( $existing_id ) ) ) !== $import_title ) { unset( $form['export_key'] ); $existing_id = 0; } if ( ! $existing_id ) { $existing_id = $this->find_form_id_by_title( isset( $form['title'] ) ? $form['title'] : '' ); } if ( $existing_id && ! self::is_editable_form( $existing_id ) ) { $existing_id = 0; } if ( ! $existing_id ) { $form_id = $this->import_one_form( $form ); $this->redirect_after_form_import( is_wp_error( $form_id ) ? 'import_error' : 'imported_one' ); } if ( $this->is_identical_to_import( $existing_id, $form ) ) { $this->redirect_after_form_import( 'import_identical' ); } $token = wp_generate_password( self::IMPORT_TOKEN_LENGTH, false ); set_transient( 'holmesforms_import_pending_' . $token, array( 'form' => $form, 'overwrite' => $existing_id, 'user' => get_current_user_id(), ), self::IMPORT_PENDING_SECONDS ); $this->redirect_after_form_import( 'import_conflict', array( 'holmesforms_it' => $token ) ); } public function handle_import_confirm() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, the import token; the nonce is checked on the next line $token = isset( $_GET['holmesforms_it'] ) ? sanitize_text_field( wp_unslash( $_GET['holmesforms_it'] ) ) : ''; if ( '' === $token || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_import_confirm_' . $token ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $pending = get_transient( 'holmesforms_import_pending_' . $token ); delete_transient( 'holmesforms_import_pending_' . $token ); if ( ! is_array( $pending ) || empty( $pending['form'] ) || get_current_user_id() !== (int) $pending['user'] ) { $this->redirect_after_form_import( 'import_expired' ); } $form_id = $this->import_one_form( (array) $pending['form'], (int) $pending['overwrite'] ); $this->redirect_after_form_import( is_wp_error( $form_id ) ? 'import_error' : 'imported_over' ); } private function render_import_conflict_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, the import token, matched against the waiting import $token = isset( $_GET['holmesforms_it'] ) ? sanitize_text_field( wp_unslash( $_GET['holmesforms_it'] ) ) : ''; $pending = '' !== $token ? get_transient( 'holmesforms_import_pending_' . $token ) : false; if ( ! is_array( $pending ) || empty( $pending['overwrite'] ) ) { echo '

' . esc_html__( 'That import was no longer waiting. Please choose the file again.', 'holmesportfolio-forms' ) . '

'; return; } $form_title = get_the_title( (int) $pending['overwrite'] ); $confirm_url = wp_nonce_url( admin_url( 'admin-post.php?action=holmesforms_import_confirm&holmesforms_it=' . rawurlencode( $token ) ), 'holmesforms_import_confirm_' . $token ); echo '

' . esc_html__( 'A form with this name is already here.', 'holmesportfolio-forms' ) . ' ' . esc_html( sprintf( /* translators: %s: form title */ __( 'Importing will overwrite “%s”. Its current version is kept in that form’s history. To keep both, rename the form in the file and import again.', 'holmesportfolio-forms' ), $form_title ) ) . '

' . esc_html( sprintf( /* translators: %s: form title */ __( 'Overwrite “%s”', 'holmesportfolio-forms' ), $form_title ) ) . ' ' . esc_html__( 'Cancel', 'holmesportfolio-forms' ) . '

'; } public function handle_import_config() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_admin_referer( 'holmesforms_import_config' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $upload_error = isset( $_FILES['holmesforms_import_file']['error'] ) ? (int) $_FILES['holmesforms_import_file']['error'] : UPLOAD_ERR_NO_FILE; if ( UPLOAD_ERR_INI_SIZE === $upload_error || UPLOAD_ERR_FORM_SIZE === $upload_error ) { $this->redirect_to_tools( array( 'holmesforms_notice' => 'import_too_big' ) ); } $import = self::read_uploaded_json( 'holmesforms_import_file', 'holmesforms_import_config' ); if ( ! is_array( $import ) || ( isset( $import['_type'] ) && 'holmesportfolio-forms-export' !== $import['_type'] ) || empty( $import['forms'] ) || ! is_array( $import['forms'] ) ) { $this->redirect_to_tools( array( 'holmesforms_notice' => 'import_error' ) ); } $forms = array_values( $import['forms'] ); $only_indexes = isset( $_POST['import_only'] ) ? sanitize_text_field( wp_unslash( $_POST['import_only'] ) ) : ''; if ( '' !== $only_indexes ) { $wanted = array_flip( array_map( 'absint', array_filter( explode( ',', $only_indexes ), 'is_numeric' ) ) ); $forms = array_values( array_intersect_key( $forms, $wanted ) ); } $should_replace = ! empty( $_POST['import_replace'] ); $counts = $this->import_forms( $forms, $should_replace ); $settings_imported = false; $keys_lost = false; if ( ! empty( $_POST['import_settings'] ) && isset( $import['settings'] ) && is_array( $import['settings'] ) ) { $keys_lost = $this->import_settings( $import['settings'] ); $settings_imported = true; } if ( ! empty( $_POST['import_settings'] ) && ! empty( $import['settings_ext'] ) && is_array( $import['settings_ext'] ) ) { do_action( 'holmesforms_import_settings', $import['settings_ext'] ); } if ( ! empty( $_POST['import_presets'] ) && isset( $import['presets'] ) && is_array( $import['presets'] ) ) { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; HP_Forms_Presets::import_bundle( $import['presets'], $should_replace ); } $args = array( 'holmesforms_notice' => 'imported', 'n' => $counts['added'], ); if ( $counts['replaced'] ) { $args['r'] = $counts['replaced']; } if ( $settings_imported ) { $args['st'] = $keys_lost ? 2 : 1; } if ( $counts['skipped'] ) { $args['s'] = $counts['skipped']; } if ( count( $forms ) > self::MAX_IMPORT_FORMS ) { $args['capped'] = 1; } $this->redirect_to_tools( $args ); } private function redirect_to_tools( array $args ) { wp_safe_redirect( add_query_arg( array_merge( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'tools', ), $args ), admin_url( 'admin.php' ) ) ); exit; } /** @return mixed Decoded JSON of the upload, or null. */ private static function read_uploaded_json( $field_name, $nonce_action ) { check_admin_referer( $nonce_action ); $upload_error = isset( $_FILES[ $field_name ]['error'] ) ? (int) $_FILES[ $field_name ]['error'] : UPLOAD_ERR_NO_FILE; $upload_path = ( UPLOAD_ERR_OK === $upload_error && isset( $_FILES[ $field_name ]['tmp_name'] ) ) ? sanitize_text_field( $_FILES[ $field_name ]['tmp_name'] ) : ''; if ( '' === $upload_path || ! is_uploaded_file( $upload_path ) ) { return null; } return json_decode( (string) file_get_contents( $upload_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads the uploaded file } /** @return int[] { added, replaced, skipped } */ private function import_forms( array $forms, $should_replace ) { list( $ids_by_title, $ids_by_export_key ) = self::existing_form_ids(); $counts = array( 'added' => 0, 'replaced' => 0, 'skipped' => 0, ); foreach ( array_slice( $forms, 0, self::MAX_IMPORT_FORMS ) as $form ) { if ( ! is_array( $form ) || ! isset( $form['pages'] ) || ! is_array( $form['pages'] ) ) { continue; } $title_key = strtolower( trim( isset( $form['title'] ) ? sanitize_text_field( $form['title'] ) : __( 'Imported form', 'holmesportfolio-forms' ) ) ); $by_key_id = ! empty( $form['export_key'] ) ? ( $ids_by_export_key[ $form['export_key'] ] ?? 0 ) : 0; $by_title_id = $ids_by_title[ $title_key ] ?? 0; $match_id = 0; if ( $should_replace ) { $match_id = $by_key_id ? $by_key_id : $by_title_id; } elseif ( $by_key_id || ( $by_title_id && $this->is_identical_to_import( $by_title_id, $form ) ) ) { ++$counts['skipped']; continue; } $form_id = $this->import_one_form( $form, $match_id ); if ( is_wp_error( $form_id ) ) { continue; } ++$counts[ $match_id ? 'replaced' : 'added' ]; } return $counts; } /** @return array[] [ title => id, export key => id ], newest first. */ private static function existing_form_ids() { $ids_by_title = array(); $ids_by_export_key = array(); foreach ( get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'any', 'numberposts' => -1, 'no_found_rows' => true, 'orderby' => 'modified', 'order' => 'DESC', ) ) as $post ) { $title_key = strtolower( trim( $post->post_title ) ); if ( '' !== $title_key && ! isset( $ids_by_title[ $title_key ] ) ) { $ids_by_title[ $title_key ] = (int) $post->ID; } $export_key = (string) get_post_meta( $post->ID, HP_Forms_Form::META_EXPORT_KEY, true ); if ( '' !== $export_key && ! isset( $ids_by_export_key[ $export_key ] ) ) { $ids_by_export_key[ $export_key ] = (int) $post->ID; } } return array( $ids_by_title, $ids_by_export_key ); } /** @return bool True when a secret key couldn't be opened here. */ private function import_settings( array $settings ) { $keys_lost = false; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; foreach ( array( 'turnstile_secret', 'recaptcha_secret' ) as $secret_key ) { if ( ! empty( $settings[ $secret_key ] ) && HP_Forms_Crypt::is_sealed( $settings[ $secret_key ] ) ) { $settings[ $secret_key ] = HP_Forms_Crypt::decrypt( $settings[ $secret_key ] ); $keys_lost = $keys_lost || '' === $settings[ $secret_key ]; } } update_option( self::SETTINGS, $this->sanitize_settings( $settings ) ); return $keys_lost; } private function redirect_after_restore( $result ) { wp_safe_redirect( add_query_arg( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'general', 'holmesforms_notice' => 'restored', 'a' => count( $result['added'] ), 'o' => count( $result['overwritten'] ), 'l' => count( $result['skipped_locked'] ), 'blocked' => empty( $result['done'] ) ? 1 : 0, 'b' => empty( $result['backup'] ) ? 0 : 1, ), admin_url( 'admin.php' ) ) ); exit; } public function handle_restore_forms() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_admin_referer( 'holmesforms_restore_forms' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $picked = isset( $_POST['pick'] ) && is_array( $_POST['pick'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['pick'] ) ) : array(); $this->redirect_after_restore( HP_Forms_Plugin::restore_picked( $picked, ! empty( $_POST['ack'] ) ) ); } public function handle_restore_blocks() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_admin_referer( 'holmesforms_restore_blocks' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; $this->redirect_after_restore( HP_Forms_Presets::restore_starters( ! empty( $_POST['force'] ) ) ); } /* Settings API */ public function register_settings() { register_setting( 'holmesforms_settings_group', self::SETTINGS, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => array(), ) ); $general = self::SETTINGS_SLUG . '-general'; $data = self::SETTINGS_SLUG . '-data'; $email = self::SETTINGS_SLUG . '-email'; $spam = self::SETTINGS_SLUG . '-spam'; $help = self::SETTINGS_SLUG . '-help'; add_settings_section( 'holmesforms_style', __( 'Styling', 'holmesportfolio-forms' ), '__return_false', $general ); add_settings_field( 'style_mode', __( 'Default style mode', 'holmesportfolio-forms' ), array( $this, 'field_style_mode' ), $general, 'holmesforms_style', array( 'label_for' => 'hpf-style-mode' ) ); add_settings_field( 'accent_colour', __( 'Accent colour (Plain style)', 'holmesportfolio-forms' ), array( $this, 'field_accent_colour' ), $general, 'holmesforms_style', array( 'label_for' => 'hpf-accent-colour' ) ); add_settings_field( 'style_classes', __( 'Styling with your own CSS', 'holmesportfolio-forms' ), array( $this, 'field_style_classes' ), $general, 'holmesforms_style' ); add_settings_field( 'nojs', __( 'Visitors without JavaScript', 'holmesportfolio-forms' ), array( $this, 'field_nojs' ), $general, 'holmesforms_style' ); add_settings_section( 'holmesforms_working', __( 'Working days', 'holmesportfolio-forms' ), '__return_false', $general ); add_settings_field( 'working_days', __( 'Working days', 'holmesportfolio-forms' ), array( $this, 'field_working_days' ), $general, 'holmesforms_working' ); add_settings_field( 'bank_holiday_region', __( 'Bank holidays to leave out', 'holmesportfolio-forms' ), array( $this, 'field_bank_holiday_region' ), $general, 'holmesforms_working', array( 'label_for' => 'hpf-bank-holiday-region' ) ); add_settings_field( 'bank_holiday_updates', __( 'Keep the list up to date', 'holmesportfolio-forms' ), array( $this, 'field_bank_holiday_updates' ), $general, 'holmesforms_working', array( 'label_for' => 'hpf-bank-holiday-updates' ) ); add_settings_section( 'holmesforms_data', __( 'Data & privacy', 'holmesportfolio-forms' ), '__return_false', $data ); if ( ! apply_filters( 'holmesforms_storage_control_takeover', false ) ) { add_settings_field( 'store_mode', __( 'Store submissions', 'holmesportfolio-forms' ), array( $this, 'field_store_mode' ), $data, 'holmesforms_data' ); add_settings_field( 'store_forms', __( 'Which forms to store', 'holmesportfolio-forms' ), array( $this, 'field_store_forms' ), $data, 'holmesforms_data' ); } add_settings_field( 'retention_days', __( 'Delete entries after (days)', 'holmesportfolio-forms' ), array( $this, 'field_retention' ), $data, 'holmesforms_data', array( 'label_for' => 'hpf-retention-days' ) ); add_settings_field( 'retention_overrides', __( 'Per-form retention', 'holmesportfolio-forms' ), array( $this, 'field_retention_overrides' ), $data, 'holmesforms_data' ); add_settings_field( 'storage_usage', __( 'Storage used', 'holmesportfolio-forms' ), array( $this, 'field_storage_usage' ), $data, 'holmesforms_data' ); add_settings_field( 'storage_diagnostics', __( 'What is using the most', 'holmesportfolio-forms' ), array( $this, 'field_storage_diagnostics' ), $data, 'holmesforms_data' ); add_settings_field( 'store_ip', __( 'Record the submitter IP', 'holmesportfolio-forms' ), array( $this, 'field_store_ip' ), $data, 'holmesforms_data' ); add_settings_field( 'encryption', __( 'Encryption at rest', 'holmesportfolio-forms' ), array( $this, 'field_encryption' ), $data, 'holmesforms_data' ); add_settings_field( 'ref_prefix', __( 'Reference prefix', 'holmesportfolio-forms' ), array( $this, 'field_ref_prefix' ), $data, 'holmesforms_data', array( 'label_for' => 'hpf-ref-prefix' ) ); add_settings_section( 'holmesforms_signin', __( 'Protect the sign-in', 'holmesportfolio-forms' ), array( $this, 'signin_intro' ), $data ); add_settings_section( 'holmesforms_email', __( 'Email appearance', 'holmesportfolio-forms' ), array( $this, 'email_intro' ), $email ); add_settings_field( 'from_email', __( 'Default from address', 'holmesportfolio-forms' ), array( $this, 'field_from_email' ), $email, 'holmesforms_email', array( 'label_for' => 'hpf-from-email' ) ); add_settings_field( 'send_background', __( 'When emails are sent', 'holmesportfolio-forms' ), array( $this, 'field_send_background' ), $email, 'holmesforms_email' ); add_settings_field( 'mail_cap', __( 'Sending limit', 'holmesportfolio-forms' ), array( $this, 'field_mail_cap' ), $email, 'holmesforms_email' ); add_settings_field( 'email_look', __( 'Email look', 'holmesportfolio-forms' ), array( $this, 'field_email_look' ), $email, 'holmesforms_email' ); add_settings_field( 'email_header', __( 'Header on every email', 'holmesportfolio-forms' ), array( $this, 'field_email_header' ), $email, 'holmesforms_email', array( 'label_for' => 'hpf-email-header' ) ); add_settings_field( 'email_footer', __( 'Footer on every email', 'holmesportfolio-forms' ), array( $this, 'field_email_footer' ), $email, 'holmesforms_email', array( 'label_for' => 'hpf-email-footer' ) ); add_settings_field( 'email_example', __( 'A basic example', 'holmesportfolio-forms' ), array( $this, 'field_email_example' ), $email, 'holmesforms_email' ); add_settings_section( 'holmesforms_spam', __( 'Spam protection', 'holmesportfolio-forms' ), array( $this, 'spam_intro' ), $spam ); add_settings_field( 'spam_provider', __( 'Additional challenge', 'holmesportfolio-forms' ), array( $this, 'field_spam_provider' ), $spam, 'holmesforms_spam', array( 'label_for' => 'hpf-spam-provider' ) ); add_settings_field( 'captcha_keys', __( 'Challenge keys', 'holmesportfolio-forms' ), array( $this, 'field_captcha_keys' ), $spam, 'holmesforms_spam' ); add_settings_field( 'visitor_ip', __( 'Visitor address', 'holmesportfolio-forms' ), array( $this, 'field_visitor_ip' ), $spam, 'holmesforms_spam', array( 'label_for' => 'hpf-visitor-ip' ) ); add_settings_field( 'flood_limit', __( 'Unusual activity alert', 'holmesportfolio-forms' ), array( $this, 'field_flood_limit' ), $spam, 'holmesforms_spam', array( 'label_for' => 'hpf-flood-limit' ) ); add_settings_section( 'holmesforms_words', __( 'Blocked words', 'holmesportfolio-forms' ), array( $this, 'words_intro' ), $spam ); add_settings_field( 'words_on', __( 'Check answers', 'holmesportfolio-forms' ), array( $this, 'field_words_on' ), $spam, 'holmesforms_words' ); add_settings_field( 'words_own', __( 'Your own blocked words', 'holmesportfolio-forms' ), array( $this, 'field_words_own' ), $spam, 'holmesforms_words', array( 'label_for' => 'hpf-words-own' ) ); add_settings_field( 'words_action', __( 'When an answer has a blocked word', 'holmesportfolio-forms' ), array( $this, 'field_words_action' ), $spam, 'holmesforms_words' ); add_settings_field( 'words_message', __( 'Message to the visitor', 'holmesportfolio-forms' ), array( $this, 'field_words_message' ), $spam, 'holmesforms_words', array( 'label_for' => 'hpf-words-message' ) ); add_settings_field( 'words_forms', __( 'Different for a form', 'holmesportfolio-forms' ), array( $this, 'field_words_forms' ), $spam, 'holmesforms_words' ); add_settings_section( 'holmesforms_help', __( 'User guide', 'holmesportfolio-forms' ), array( $this, 'guide_intro' ), $help ); add_settings_section( 'holmesforms_support', __( 'Report an issue', 'holmesportfolio-forms' ), array( $this, 'support_intro' ), $help ); } /** @return array */ public static function settings_tabs() { $tabs = array( 'overview' => array( 'label' => __( 'Overview', 'holmesportfolio-forms' ), 'group' => '', ), 'general' => array( 'label' => __( 'General', 'holmesportfolio-forms' ), 'group' => 'forms', 'desc' => __( 'Form styling and custom CSS, what visitors without JavaScript see, working days and bank holidays, and restoring the starter forms and blocks.', 'holmesportfolio-forms' ), ), 'email' => array( 'label' => __( 'Email', 'holmesportfolio-forms' ), 'group' => 'email', 'desc' => __( 'How notification emails look, the address they come from, and sending yourself a test.', 'holmesportfolio-forms' ), ), 'connections' => array( 'label' => __( 'Connections', 'holmesportfolio-forms' ), 'group' => 'email', 'desc' => __( 'Postcode lookup, which fills in the town and county.', 'holmesportfolio-forms' ), ), 'data' => array( 'label' => __( 'Data & privacy', 'holmesportfolio-forms' ), 'group' => 'data', 'desc' => __( 'Whether answers are stored and for how long, encryption at rest, recording IP addresses, the reference prefix, storage used, and protecting the staff sign-in.', 'holmesportfolio-forms' ), ), 'spam' => array( 'label' => __( 'Spam', 'holmesportfolio-forms' ), 'group' => 'data', 'desc' => __( 'The always-on checks, an optional challenge, an alert for unusual activity, and blocked words such as swearing.', 'holmesportfolio-forms' ), ), 'tools' => array( 'label' => __( 'Backup and import', 'holmesportfolio-forms' ), 'group' => 'data', 'desc' => __( 'Automatic backups of your forms and settings, downloading a backup, and importing a backup or a single form.', 'holmesportfolio-forms' ), ), 'help' => array( 'label' => __( 'Help & support', 'holmesportfolio-forms' ), 'group' => 'help', 'desc' => apply_filters( 'holmesforms_lead_capture_enabled', false ) ? __( 'The user guide, reporting a problem, the setup wizard, and signing up for updates.', 'holmesportfolio-forms' ) : __( 'The user guide, reporting a problem, and the setup wizard.', 'holmesportfolio-forms' ), ), ); $tabs = (array) apply_filters( 'holmesforms_settings_tabs', $tabs ); return $tabs; } private static function settings_hub_groups() { return array( 'forms' => __( 'Forms', 'holmesportfolio-forms' ), 'email' => __( 'Email & connections', 'holmesportfolio-forms' ), 'data' => __( 'Data & privacy', 'holmesportfolio-forms' ), 'team' => __( 'Team & access', 'holmesportfolio-forms' ), 'help' => __( 'Help & suggestions', 'holmesportfolio-forms' ), ); } private static function settings_keys_by_tab() { return array( 'general' => array( 'style_mode', 'accent_colour', 'nojs', 'bank_holiday_region', 'bank_holiday_updates', 'working_days' ), 'data' => array( 'store_mode', 'store_forms', 'retention_days', 'retention_overrides', 'store_ip', 'ref_prefix' ), 'email' => array( 'from_email', 'send_background', 'mail_cap_hour', 'mail_cap_day', 'email_header', 'email_footer', 'email_page_colour', 'email_panel_colour', 'email_text_colour', 'email_heading_colour', 'email_link_colour', 'email_footer_colour', 'email_footer_text', 'email_font', 'email_font_size', 'email_width' ), 'spam' => array( 'spam_provider', 'turnstile_site', 'turnstile_secret', 'recaptcha_site', 'recaptcha_secret', 'visitor_ip', 'flood_limit', 'words_on', 'words_builtin', 'words_own', 'words_wp', 'words_action', 'words_message', 'words_forms' ), 'help' => array(), ); } public function spam_intro() { echo wp_kses( '

' . esc_html__( 'The GOV.UK Service Manual advises against CAPTCHA tests. The honeypot, timing check and rate limit are always on and need no setup. Turnstile and reCAPTCHA add a challenge that visitors see, so choose None unless you have a specific reason.', 'holmesportfolio-forms' ) . self::guide_link_html( 'spam' ) . '

', HP_Forms_Kses::admin() ); } public function words_intro() { echo wp_kses( '

' . esc_html__( 'Checks what visitors type in text inputs and text areas for swear words and slurs. It matches whole words only, so place names like Scunthorpe aren’t caught. Capitals and simple disguises like sh1t or f.u.c.k are still caught. Email and web addresses are not checked. Nothing leaves your site.', 'holmesportfolio-forms' ) . self::guide_link_html( 'blocked-words' ) . '

', HP_Forms_Kses::admin() ); } public function field_words_on() { $words = HP_Forms_Words::settings(); $option_name = esc_attr( self::SETTINGS ); echo '
' . esc_html__( 'Check answers', 'holmesportfolio-forms' ) . ''; echo wp_kses( '', HP_Forms_Kses::admin() ); echo wp_kses( '', HP_Forms_Kses::admin() ); echo wp_kses( '', HP_Forms_Kses::admin() ); echo '
'; echo '
' . esc_html__( 'See the built-in list', 'holmesportfolio-forms' ) . '

' . esc_html( implode( ', ', HP_Forms_Words::BUILT_IN ) ) . '

' . esc_html__( 'Words that also have an everyday meaning, like cock, bitch or faggots (the dish), are left out. That way an answer using them in their everyday sense isn’t refused. Add them below if your forms need them.', 'holmesportfolio-forms' ) . '

'; } public function field_words_own() { printf( '

%s

', esc_attr( self::SETTINGS ), esc_textarea( HP_Forms_Words::settings()['own'] ), esc_html__( 'One word or phrase per line. Each is matched as a whole word, whatever the capitals.', 'holmesportfolio-forms' ) ); } public function field_words_action() { $words = HP_Forms_Words::settings(); $options = array( 'refuse' => __( 'Ask them to change it before the form can be sent (recommended)', 'holmesportfolio-forms' ), 'flag' => __( 'Accept it and highlight the words for staff', 'holmesportfolio-forms' ), 'spam' => __( 'Accept it but save it as spam, with no emails sent', 'holmesportfolio-forms' ), ); echo '
' . esc_html__( 'When an answer has a blocked word', 'holmesportfolio-forms' ) . ''; self::render_setting_radios( 'words_action', $options, $words['action'] ); echo '

' . esc_html__( 'Whichever you choose, staff see the words highlighted when they open the entry.', 'holmesportfolio-forms' ) . '

'; } public function field_words_message() { printf( '

%s %s

', esc_attr( self::SETTINGS ), esc_attr( HP_Forms_Words::settings()['message'] ), /* translators: %s is typed as it is; it is not replaced here. */ esc_html__( 'Shown at the question when an answer is refused. Put %s where you want the words found to appear. Leave the box blank to use this wording:', 'holmesportfolio-forms' ), esc_html( HP_Forms_Words::default_message() ) ); } public function field_words_forms() { $words = HP_Forms_Words::settings(); $forms = get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => array( 'publish', 'draft' ), 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); $options = array( '' => __( 'Same as above', 'holmesportfolio-forms' ), 'refuse' => __( 'Ask them to change it', 'holmesportfolio-forms' ), 'flag' => __( 'Accept and highlight for staff', 'holmesportfolio-forms' ), 'spam' => __( 'Save as spam', 'holmesportfolio-forms' ), 'off' => __( 'Do not check', 'holmesportfolio-forms' ), ); if ( ! $forms ) { echo '

' . esc_html__( 'No forms yet.', 'holmesportfolio-forms' ) . '

'; return; } echo ''; foreach ( $forms as $form ) { $current = isset( $words['forms'][ $form->ID ] ) ? (string) $words['forms'][ $form->ID ] : ''; echo ''; } echo '
' . esc_html__( 'Form', 'holmesportfolio-forms' ) . '' . esc_html__( 'Blocked words', 'holmesportfolio-forms' ) . '
' . esc_html( get_the_title( $form ) ) . '

' . esc_html__( 'For example, a complaints form could accept and highlight, so nobody quoting what was said to them is turned away.', 'holmesportfolio-forms' ) . '

'; } public function signin_intro() { echo wp_kses( '

' . esc_html__( 'The strongest protection for the admin side is set up outside this plugin. Lock wp-admin and the login page to your own network in the .htaccess file, and ask staff for a second step when they sign in, for example with the free Two-Factor plugin.', 'holmesportfolio-forms' ) . self::guide_link_html( 'protect' ) . '

', HP_Forms_Kses::admin() ); } public function guide_intro() { $url = self::guide_url(); printf( '

%1$s %3$s.

', esc_html__( 'The full user guide covers how to build a form, every field and setting, show and hide rules, and the starter forms, including the request forms. Only signed-in staff can open it. Find it at', 'holmesportfolio-forms' ), esc_url( $url ), esc_html( preg_replace( '#^https?://#', '', $url ) ) . ' ' . esc_html__( '(opens in new tab)', 'holmesportfolio-forms' ) ); } public function support_intro() { printf( '

%s %s.

', esc_html__( 'Need help, have a suggestion, or found a bug? Tell us through the support page. For a bug, say what you did, what you expected and what happened, and give your WordPress and PHP versions. The support page is at', 'holmesportfolio-forms' ), esc_url( 'https://holmesportfolio.co.uk/support/' ), esc_html( preg_replace( '#^https?://#', '', 'https://holmesportfolio.co.uk/support/' ) ) . ' ' . esc_html__( '(opens in new tab)', 'holmesportfolio-forms' ) ); } public function sanitize_settings( $input ) { $input = is_array( $input ) ? $input : array(); $clean = $this->clean_settings( $input ); $tab = isset( $input['__tab'] ) ? sanitize_key( $input['__tab'] ) : ''; $keys_by_tab = self::settings_keys_by_tab(); if ( ! isset( $keys_by_tab[ $tab ] ) ) { return $clean; } $stored = get_option( self::SETTINGS, array() ); $merged = is_array( $stored ) ? $stored : array(); foreach ( $keys_by_tab[ $tab ] as $key ) { if ( array_key_exists( $key, $clean ) ) { $merged[ $key ] = $clean[ $key ]; } } return $merged; } private function clean_settings( $input ) { $input = is_array( $input ) ? $input : array(); return array_merge( $this->clean_style_settings( $input ), $this->clean_email_settings( $input ), $this->clean_storage_settings( $input ), $this->clean_working_day_settings( $input ), array( 'ref_prefix' => $this->clean_ref_prefix( $input ) ), $this->clean_spam_settings( $input ), $this->clean_blocked_word_settings( $input ) ); } private function clean_style_settings( array $input ) { $clean = array(); $style_mode = isset( $input['style_mode'] ) ? sanitize_key( $input['style_mode'] ) : 'gds'; $clean['style_mode'] = in_array( $style_mode, array( 'gds', 'bootstrap', 'plain', 'none' ), true ) ? $style_mode : 'gds'; $clean['accent_colour'] = $this->clean_accent_colour( $input ); $clean['nojs'] = ( isset( $input['nojs'] ) && 'allow' === $input['nojs'] ) ? 'allow' : 'block'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; $all_builtins = wp_list_pluck( HP_Forms_Presets::builtins(), 'id' ); foreach ( array( 'presets_hidden', HP_Forms_Presets::SETTING_UNLOCKED ) as $preset_setting ) { $clean[ $preset_setting ] = ( isset( $input[ $preset_setting ] ) && is_array( $input[ $preset_setting ] ) ) ? array_values( array_intersect( $all_builtins, array_map( 'sanitize_key', $input[ $preset_setting ] ) ) ) : array(); } return $clean; } private function clean_accent_colour( array $input ) { $stored = (string) $this->get_setting( 'accent_colour', '' ); $typed = isset( $input['accent_colour'] ) ? trim( (string) $input['accent_colour'] ) : $stored; if ( '' === $typed ) { return ''; } $colour = sanitize_hex_color( '#' === $typed[0] ? $typed : '#' . $typed ); if ( ! $colour ) { add_settings_error( self::SETTINGS, 'accent_colour', __( 'Accent colour not changed. Type it as # and then six letters or numbers, for example #005ea5.', 'holmesportfolio-forms' ) ); return $stored; } $ratio = HP_Forms_Form::contrast_on_white( $colour ); if ( $ratio < HP_Forms_Form::MIN_ACCENT_CONTRAST ) { /* translators: 1: the colour typed, 2: its contrast ratio against white, for example 3.2. */ add_settings_error( self::SETTINGS, 'accent_colour', sprintf( __( 'Accent colour not changed. %1$s has a contrast of %2$s to 1 against white. It needs at least 4.5 to 1 so buttons and links are easy to read. Pick a darker colour.', 'holmesportfolio-forms' ), $colour, number_format_i18n( floor( $ratio * 10 ) / 10, 1 ) ) ); return $stored; } return strtolower( $colour ); } private function clean_email_settings( array $input ) { $from_email = isset( $input['from_email'] ) ? sanitize_email( trim( (string) $input['from_email'] ) ) : ''; $clean = array( 'from_email' => is_email( $from_email ) ? $from_email : '', 'send_background' => isset( $input['send_background'] ) && '0' === (string) $input['send_background'] ? '0' : '1', 'mail_cap_hour' => self::clean_mail_cap( $input['mail_cap_hour'] ?? '' ), 'mail_cap_day' => self::clean_mail_cap( $input['mail_cap_day'] ?? '' ), 'email_header' => isset( $input['email_header'] ) ? wp_kses_post( (string) $input['email_header'] ) : '', 'email_footer' => isset( $input['email_footer'] ) ? wp_kses_post( (string) $input['email_footer'] ) : '', ); return array_merge( $clean, $this->clean_email_look( $input ) ); } /** The Email look colours, font, size and width; a colour that fails contrast keeps its saved value. */ private function clean_email_look( array $input ) { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-mailer.php'; $defaults = HP_Forms_Mailer::LOOK_DEFAULTS; $clean = array(); foreach ( $defaults as $key => $default ) { if ( is_int( $default ) ) { $range = 'email_width' === $key ? HP_Forms_Mailer::LOOK_WIDTH_RANGE : HP_Forms_Mailer::LOOK_FONT_SIZE_RANGE; $typed = isset( $input[ $key ] ) ? absint( $input[ $key ] ) : 0; $clean[ $key ] = $typed ? (string) min( $range[1], max( $range[0], $typed ) ) : ''; } elseif ( 'email_font' === $key ) { $typed = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : ''; $clean[ $key ] = isset( HP_Forms_Mailer::LOOK_FONTS[ $typed ] ) ? $typed : ''; } else { $clean[ $key ] = $this->clean_email_colour( $input, $key ); } } $labels = self::email_look_labels(); $panel = '' !== $clean['email_panel_colour'] ? $clean['email_panel_colour'] : $defaults['email_panel_colour']; $footer = '' !== $clean['email_footer_colour'] ? $clean['email_footer_colour'] : $panel; $pairs = array( 'email_text_colour' => $panel, 'email_heading_colour' => $panel, 'email_link_colour' => $panel, 'email_footer_text' => $footer, ); foreach ( $pairs as $key => $behind ) { $colour = '' !== $clean[ $key ] ? $clean[ $key ] : ( 'email_footer_text' === $key ? ( '' !== $clean['email_text_colour'] ? $clean['email_text_colour'] : $defaults['email_text_colour'] ) : $defaults[ $key ] ); $ratio = HP_Forms_Form::contrast_between( $colour, $behind ); if ( $ratio >= HP_Forms_Form::MIN_ACCENT_CONTRAST ) { continue; } add_settings_error( self::SETTINGS, $key, sprintf( /* translators: 1: the setting name, 2: the colour, 3: its contrast ratio, for example 3.2 */ __( '%1$s not changed. %2$s has a contrast of %3$s to 1 against the colour behind it. It needs at least 4.5 to 1 so the email is easy to read.', 'holmesportfolio-forms' ), $labels[ $key ], $colour, number_format_i18n( floor( $ratio * 10 ) / 10, 1 ) ) ); $clean[ $key ] = (string) $this->get_setting( $key, '' ); } return $clean; } /** '' for the default, or a lower-case six-digit hex colour; anything else keeps the saved value. */ private function clean_email_colour( array $input, $key ) { $stored = (string) $this->get_setting( $key, '' ); $typed = isset( $input[ $key ] ) ? trim( (string) $input[ $key ] ) : $stored; if ( '' === $typed ) { return ''; } $colour = sanitize_hex_color( '#' === $typed[0] ? $typed : '#' . $typed ); if ( ! $colour || 7 !== strlen( $colour ) ) { $labels = self::email_look_labels(); /* translators: %s: the setting name */ add_settings_error( self::SETTINGS, $key, sprintf( __( '%s not changed. Type it as # and then six letters or numbers, for example #005ea5.', 'holmesportfolio-forms' ), $labels[ $key ] ) ); return $stored; } return strtolower( $colour ); } /** @return string[] Setting key => the label shown on the Email look panel. */ private static function email_look_labels() { return array( 'email_page_colour' => __( 'Page colour', 'holmesportfolio-forms' ), 'email_panel_colour' => __( 'Panel colour', 'holmesportfolio-forms' ), 'email_text_colour' => __( 'Text colour', 'holmesportfolio-forms' ), 'email_heading_colour' => __( 'Heading colour', 'holmesportfolio-forms' ), 'email_link_colour' => __( 'Link colour', 'holmesportfolio-forms' ), 'email_footer_colour' => __( 'Footer colour', 'holmesportfolio-forms' ), 'email_footer_text' => __( 'Footer text colour', 'holmesportfolio-forms' ), 'email_font' => __( 'Font', 'holmesportfolio-forms' ), 'email_font_size' => __( 'Text size', 'holmesportfolio-forms' ), 'email_width' => __( 'Width', 'holmesportfolio-forms' ), ); } /** @return string A whole number of emails, or '' for no limit. */ private static function clean_mail_cap( $value ) { $value = (int) preg_replace( '/[^0-9]/', '', (string) $value ); return $value > 0 ? (string) min( $value, self::MAX_MAIL_CAP ) : ''; } private function clean_storage_settings( array $input ) { $clean = array(); $store_mode = isset( $input['store_mode'] ) ? sanitize_key( $input['store_mode'] ) : 'all'; $clean['store_mode'] = in_array( $store_mode, array( 'all', 'per_form', 'never' ), true ) ? $store_mode : 'all'; $clean['store_forms'] = array(); if ( isset( $input['store_forms'] ) && is_array( $input['store_forms'] ) ) { foreach ( $input['store_forms'] as $form_id => $is_stored ) { $clean['store_forms'][ absint( $form_id ) ] = empty( $is_stored ) ? 0 : 1; } } $bounds = HP_Forms_Storage::retention_bounds(); $retention_days = isset( $input['retention_days'] ) ? absint( $input['retention_days'] ) : 0; $clean['retention_days'] = ( $retention_days >= 1 ) ? (int) min( $bounds['max'], max( $bounds['min'], $retention_days ) ) : $bounds['default']; $clean['retention_overrides'] = array(); if ( isset( $input['retention_overrides'] ) && is_array( $input['retention_overrides'] ) ) { foreach ( $input['retention_overrides'] as $form_id => $days ) { $form_id = absint( $form_id ); $days = absint( $days ); if ( $form_id && $days >= 1 && get_post_type( $form_id ) === HOLMESFORMS_POST_TYPE ) { $clean['retention_overrides'][ $form_id ] = (int) min( $bounds['max'], max( $bounds['min'], $days ) ); } } } $clean['store_ip'] = ! empty( $input['store_ip'] ); return $clean; } private function clean_working_day_settings( array $input ) { $region = isset( $input['bank_holiday_region'] ) ? sanitize_key( $input['bank_holiday_region'] ) : HP_Forms_Holidays::DEFAULT_REGION; return array( 'bank_holiday_region' => isset( HP_Forms_Holidays::modes()[ $region ] ) ? $region : HP_Forms_Holidays::DEFAULT_REGION, 'bank_holiday_updates' => ! empty( $input['bank_holiday_updates'] ), 'working_days' => HP_Forms_Holidays::clean_days( isset( $input['working_days'] ) ? $input['working_days'] : array() ), ); } private function clean_ref_prefix( array $input ) { $prefix = isset( $input['ref_prefix'] ) ? preg_replace( '/[^A-Za-z0-9]/', '', (string) $input['ref_prefix'] ) : ''; return '' !== $prefix ? substr( $prefix, 0, self::MAX_REF_PREFIX_LENGTH ) : 'ENQ'; } private function clean_spam_settings( array $input ) { $clean = array(); $provider = isset( $input['spam_provider'] ) ? sanitize_key( $input['spam_provider'] ) : 'none'; $clean['spam_provider'] = in_array( $provider, array( 'none', 'turnstile', 'recaptcha' ), true ) ? $provider : 'none'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; $stored = (array) get_option( self::SETTINGS, array() ); foreach ( array( 'turnstile_site', 'turnstile_secret', 'recaptcha_site', 'recaptcha_secret' ) as $key ) { $is_current_provider = 'none' !== $clean['spam_provider'] && 0 === strpos( $key, $clean['spam_provider'] ); if ( ! $is_current_provider ) { $clean[ $key ] = isset( $stored[ $key ] ) ? (string) $stored[ $key ] : ''; continue; } $clean[ $key ] = isset( $input[ $key ] ) ? sanitize_text_field( $input[ $key ] ) : ''; $site_cleared = isset( $input[ $clean['spam_provider'] . '_site' ] ) && '' === trim( (string) $input[ $clean['spam_provider'] . '_site' ] ); if ( $site_cleared && false !== strpos( $key, '_secret' ) ) { $clean[ $key ] = ''; continue; } if ( $is_current_provider && false !== strpos( $key, '_secret' ) ) { if ( '' === $clean[ $key ] ) { $clean[ $key ] = isset( $stored[ $key ] ) ? (string) $stored[ $key ] : ''; } elseif ( ! HP_Forms_Crypt::is_sealed( $clean[ $key ] ) ) { $clean[ $key ] = HP_Forms_Crypt::can_write() ? HP_Forms_Crypt::encrypt( $clean[ $key ] ) : ( isset( $stored[ $key ] ) ? (string) $stored[ $key ] : '' ); } } } $visitor_ip = isset( $input['visitor_ip'] ) ? sanitize_key( $input['visitor_ip'] ) : 'direct'; $clean['visitor_ip'] = in_array( $visitor_ip, array( 'direct', 'cloudflare', 'proxy' ), true ) ? $visitor_ip : 'direct'; $clean['flood_limit'] = isset( $input['flood_limit'] ) ? max( 0, min( self::MAX_FLOOD_LIMIT, (int) $input['flood_limit'] ) ) : HOLMESFORMS_DEFAULT_FLOOD_LIMIT; return $clean; } private function clean_blocked_word_settings( array $input ) { $clean = array(); $clean['words_on'] = ! empty( $input['words_on'] ); $clean['words_builtin'] = ! empty( $input['words_builtin'] ); $clean['words_wp'] = ! empty( $input['words_wp'] ); $clean['words_own'] = isset( $input['words_own'] ) ? mb_substr( sanitize_textarea_field( (string) $input['words_own'] ), 0, self::MAX_OWN_WORDS_LENGTH ) : ''; $clean['words_action'] = ( isset( $input['words_action'] ) && in_array( $input['words_action'], array( 'refuse', 'spam', 'flag' ), true ) ) ? $input['words_action'] : 'refuse'; $clean['words_message'] = isset( $input['words_message'] ) ? mb_substr( sanitize_text_field( (string) $input['words_message'] ), 0, self::MAX_WORDS_MESSAGE_LENGTH ) : ''; $clean['words_forms'] = array(); foreach ( ( isset( $input['words_forms'] ) && is_array( $input['words_forms'] ) ) ? $input['words_forms'] : array() as $form_id => $words_action ) { if ( absint( $form_id ) && in_array( $words_action, array( 'refuse', 'spam', 'flag', 'off' ), true ) ) { $clean['words_forms'][ absint( $form_id ) ] = $words_action; } } return $clean; } private function get_setting( $key, $fallback = '' ) { $settings = get_option( self::SETTINGS, array() ); return isset( $settings[ $key ] ) ? $settings[ $key ] : $fallback; } private static function render_setting_select( $id, $setting_key, array $choices, $current ) { echo ''; } private static function render_select_options( array $choices, $current ) { foreach ( $choices as $value => $label ) { echo ''; } } private static function render_setting_radios( $setting_key, array $choices, $current ) { foreach ( $choices as $value => $label ) { echo ''; } } public function field_bank_holiday_region() { $value = HP_Forms_Holidays::site_region(); self::render_setting_select( 'hpf-bank-holiday-region', 'bank_holiday_region', HP_Forms_Holidays::modes(), $value ); echo '

' . esc_html__( 'Deadlines counted in working days, such as 20 working days, skip these bank holidays. So do response times, where they’re used. Pick one part of the UK, all of them, or none. A form can use a different choice on its builder Setup tab. Put [working-days-note] in an email or message to show how a deadline was counted.', 'holmesportfolio-forms' ) . '

'; } public function field_bank_holiday_updates() { $last = HP_Forms_Holidays::last_date(); printf( '', esc_attr( self::SETTINGS ), checked( HP_Forms_Holidays::is_updating(), true, false ), esc_html__( 'Fetch the latest bank holidays from GOV.UK once a week', 'holmesportfolio-forms' ) ); /* translators: %s: date of the last bank holiday on the list */ echo '

' . esc_html( sprintf( __( 'The plugin comes with GOV.UK\'s list, which runs to %s. Each plugin update brings a newer copy. Turn this on to fetch updates from gov.uk every week as well. Only the public list is fetched, and no personal data is sent.', 'holmesportfolio-forms' ), $last ? wp_date( get_option( 'date_format' ), strtotime( $last . ' 12:00:00' ) ) : __( 'no date', 'holmesportfolio-forms' ) ) ) . '

'; if ( $last && ! HP_Forms_Holidays::is_updating() && strtotime( $last ) < time() + self::BANK_HOLIDAY_WARNING_SECONDS ) { echo '

' . esc_html__( 'The bank holiday list runs out within six months. Turn this on, or update the plugin, so working-day dates stay right.', 'holmesportfolio-forms' ) . '

'; } } public function field_working_days() { $week = HP_Forms_Holidays::working_days(); echo '
' . esc_html__( 'Days that count as working days', 'holmesportfolio-forms' ) . ''; foreach ( HP_Forms_Holidays::day_names() as $day_number => $day_name ) { printf( '', esc_attr( self::SETTINGS ), (int) $day_number, checked( in_array( $day_number, $week, true ), true, false ), esc_html( $day_name ) ); } echo '

' . esc_html__( 'Monday to Friday by default. Tick Saturday and Sunday for a seven-day operation. At least one day is always kept.', 'holmesportfolio-forms' ) . '

'; } public function field_style_mode() { $value = $this->get_setting( 'style_mode', 'gds' ); $options = array( 'gds' => __( 'GOV.UK Design System (recommended)', 'holmesportfolio-forms' ), 'bootstrap' => __( 'Bootstrap 5', 'holmesportfolio-forms' ), 'plain' => __( 'Plain, the plugin\'s own tidy styling', 'holmesportfolio-forms' ), 'none' => __( 'None, inherit the theme', 'holmesportfolio-forms' ), ); self::render_setting_select( 'hpf-style-mode', 'style_mode', $options, $value ); echo wp_kses( '

' . esc_html__( 'The look every form on the site uses. The GOV.UK Design System is made by the Government Digital Service, and the UK government uses it for its own online services. It is the recommended choice. GOV.UK says: "Using the GOV.UK Design System in a service does not immediately make that service accessible." Test each published form. Making your forms accessible is your responsibility. GOV.UK and Bootstrap load that framework\'s own stylesheet. Plain and None use the plugin\'s own class names.', 'holmesportfolio-forms' ) . self::guide_link_html( 'builder' ) . '

', HP_Forms_Kses::admin() ); } public function field_accent_colour() { $value = (string) $this->get_setting( 'accent_colour', '' ); printf( ' ', esc_attr( self::SETTINGS ), esc_attr( $value ), esc_attr( '' !== $value ? $value : '#005ea5' ), esc_attr__( 'Pick the accent colour', 'holmesportfolio-forms' ) ); echo wp_kses( '

' . esc_html__( 'The colour of buttons, links and highlights on forms that use the Plain style. Leave it empty for the default blue. It needs a contrast of at least 4.5 to 1 against white. A colour that is too light is not saved. Forms in the GOV.UK or Bootstrap style keep their own colours.', 'holmesportfolio-forms' ) . self::guide_link_html( 'settings-accent' ) . '

', HP_Forms_Kses::admin() ); } public function field_nojs() { $value = 'allow' === $this->get_setting( 'nojs', 'block' ) ? 'allow' : 'block'; $options = array( 'block' => __( 'Hide the form and ask them to turn JavaScript on (default)', 'holmesportfolio-forms' ), 'allow' => __( 'Let them fill it in without JavaScript', 'holmesportfolio-forms' ), ); echo '
' . esc_html__( 'Visitors without JavaScript', 'holmesportfolio-forms' ) . ''; self::render_setting_radios( 'nojs', $options, $value ); echo '
'; echo wp_kses( '

' . esc_html__( 'GOV.UK\'s guidance for government services recommends that forms work without JavaScript. The plugin hides forms by default on purpose, because letting people in can cause more problems than it solves. Without JavaScript, rules that show or hide questions stop working, and so do several other features. If you let them in, every page shows at once and the form is sent in one go. A list that changes with an earlier answer shows them every choice. If someone picks a choice that doesn\'t match their earlier answer, they see an error asking them to pick another.', 'holmesportfolio-forms' ) . self::guide_link_html( 'no-js' ) . '

', HP_Forms_Kses::admin() ); if ( 'allow' === $value ) { $this->render_nojs_conflict_list(); } } private function render_nojs_conflict_list() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-render.php'; $form_ids = get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => array( 'publish', 'draft', 'private' ), 'numberposts' => -1, 'fields' => 'ids', ) ); $items_html = ''; foreach ( $form_ids as $form_id ) { $form = HP_Forms_Form::get( (int) $form_id ); $conflicts = $form ? HP_Forms_Render::list_nojs_conflicts( $form ) : array(); if ( ! $conflicts ) { continue; } $items_html .= '
  • ' . esc_html( get_the_title( $form_id ) ) . '
  • '; } echo '
    '; if ( '' === $items_html ) { echo '

    ' . esc_html__( 'None of your forms use anything that stops working without JavaScript.', 'holmesportfolio-forms' ) . '

    '; return; } echo wp_kses( '

    ' . esc_html__( 'What stops working for visitors without JavaScript', 'holmesportfolio-forms' ) . '

    ', HP_Forms_Kses::admin() ); echo '

    ' . esc_html__( 'To stop these problems, choose "Hide the form and ask them to turn JavaScript on" above. Visitors without JavaScript then see a message asking them to turn it on.', 'holmesportfolio-forms' ) . '

    '; } private static function render_nojs_builder_notice( $form_id ) { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-render.php'; if ( ! $form_id || HP_Forms_Render::is_nojs_blocked() ) { return; } $form = HP_Forms_Form::get( (int) $form_id ); $conflicts = $form ? HP_Forms_Render::list_nojs_conflicts( $form ) : array(); $seen = (array) get_post_meta( $form_id, '_holmesforms_nojs_seen', true ); $new_conflicts = array_diff_key( $conflicts, array_flip( $seen ) ); if ( array_keys( $conflicts ) !== $seen ) { update_post_meta( $form_id, '_holmesforms_nojs_seen', array_keys( $conflicts ) ); } if ( ! $new_conflicts ) { return; } echo '

    ' . esc_html__( 'This form now has something that won\'t work for visitors without JavaScript:', 'holmesportfolio-forms' ) . '

    '; if ( current_user_can( 'holmesforms_admin' ) ) { echo '

    ' . esc_html__( 'Go to Settings to hide forms from visitors without JavaScript', 'holmesportfolio-forms' ) . '

    '; } echo '

    ' . esc_html__( 'This warning shows once for each kind of problem on this form.', 'holmesportfolio-forms' ) . '

    '; } public function field_style_classes() { echo '

    ' . esc_html__( 'Want the forms to look different from what the style mode and accent colour give you? Add CSS under Appearance, Customize, Additional CSS. Start each rule with .hpf-form so it only touches the forms. On a block theme, Additional CSS is in the Site Editor under Styles. The class names below show you what to target.', 'holmesportfolio-forms' ) . '

    '; $mode_classes = array( array( __( 'Question text', 'holmesportfolio-forms' ), '.govuk-label', '.form-label', '.hpf-label' ), array( __( 'Help line under it', 'holmesportfolio-forms' ), '.govuk-hint', '.form-text', '.hpf-hint' ), array( __( 'Red error message', 'holmesportfolio-forms' ), '.govuk-error-message', '.invalid-feedback', '.hpf-error' ), array( __( 'A whole question', 'holmesportfolio-forms' ), '.govuk-form-group', '.hpf-group', '.hpf-group' ), array( __( 'A question with an error', 'holmesportfolio-forms' ), '.govuk-form-group--error', '.hpf-group--error', '.hpf-group--error' ), array( __( 'Text box', 'holmesportfolio-forms' ), '.govuk-input', '.form-control', '.hpf-input' ), array( __( 'Text area', 'holmesportfolio-forms' ), '.govuk-textarea', 'textarea.form-control', '.hpf-textarea' ), array( __( 'Dropdown', 'holmesportfolio-forms' ), '.govuk-select', '.form-select', '.hpf-select' ), array( __( 'Radio button', 'holmesportfolio-forms' ), '.govuk-radios__input', '.form-check-input', '.hpf-choice__input' ), array( __( 'Tick box', 'holmesportfolio-forms' ), '.govuk-checkboxes__input', '.form-check-input', '.hpf-choice__input' ), array( __( 'Words next to a radio button or tick box', 'holmesportfolio-forms' ), '.govuk-radios__label, .govuk-checkboxes__label', '.form-check-label', '.hpf-choice__label' ), array( __( 'Date: the row of boxes', 'holmesportfolio-forms' ), '.govuk-date-input', '.hpf-date', '.hpf-date' ), array( __( 'Date: the day, month and year boxes', 'holmesportfolio-forms' ), '.govuk-date-input__input', '.hpf-date .form-control', '.hpf-date__input' ), array( __( 'Buttons', 'holmesportfolio-forms' ), '.govuk-button', '.btn', '.hpf-button' ), array( __( 'Secondary buttons, such as Back', 'holmesportfolio-forms' ), '.govuk-button--secondary', '.btn-secondary', '.hpf-button--secondary' ), array( __( 'Error summary (the list of errors at the top)', 'holmesportfolio-forms' ), '.govuk-error-summary', '.alert-danger', '.hpf-error-summary' ), array( __( 'Alert: Notification banner', 'holmesportfolio-forms' ), '.govuk-notification-banner', '.alert-primary', '.hpf-alert--notification' ), array( __( 'Alert: Inset text', 'holmesportfolio-forms' ), '.govuk-inset-text', '.alert-secondary', '.hpf-alert--inset' ), array( __( 'Alert: Warning text', 'holmesportfolio-forms' ), '.govuk-warning-text', '.alert-warning', '.hpf-alert--warning' ), array( __( 'Alert: Error message', 'holmesportfolio-forms' ), '.govuk-error-message', '.alert-danger', '.hpf-alert--error' ), array( __( 'Thank-you message', 'holmesportfolio-forms' ), '.govuk-panel--confirmation', '.alert-success', '.hpf-panel--confirmation' ), ); $shared_classes = array( array( __( 'The whole form', 'holmesportfolio-forms' ), '.hpf-form' ), array( __( 'One page', 'holmesportfolio-forms' ), '.hpf-page' ), array( __( 'Page title', 'holmesportfolio-forms' ), '.hpf-page__title' ), array( __( 'Group', 'holmesportfolio-forms' ), '.hpf-container' ), array( __( 'Group with a heading (the box)', 'holmesportfolio-forms' ), '.hpf-container--titled' ), array( __( 'Group heading', 'holmesportfolio-forms' ), '.hpf-container__heading' ), array( __( 'Group people can fill in more than once', 'holmesportfolio-forms' ), '.hpf-container--repeat' ), array( __( 'Continue and Back', 'holmesportfolio-forms' ), '.hpf-next, .hpf-back' ), array( __( 'Heading and list inside the error summary', 'holmesportfolio-forms' ), '.hpf-error-summary__title, .hpf-error-summary__list' ), array( __( 'Text inside an alert', 'holmesportfolio-forms' ), '.hpf-alert__body' ), array( __( 'Word or character count', 'holmesportfolio-forms' ), '.hpf-wordcount' ), array( __( 'Count once it goes over the limit', 'holmesportfolio-forms' ), '.hpf-wordcount.is-over' ), array( __( 'File attachments', 'holmesportfolio-forms' ), '.hpf-attach' ), array( __( 'Answers summary', 'holmesportfolio-forms' ), '.hpf-cya' ), array( __( 'Answers summary: page heading', 'holmesportfolio-forms' ), '.hpf-cya__page' ), array( __( 'Answers summary: group heading', 'holmesportfolio-forms' ), '.hpf-cya__section' ), array( __( 'Answers summary: line after a group', 'holmesportfolio-forms' ), '.hpf-cya__group-end' ), array( __( 'Answers summary: the list of answers', 'holmesportfolio-forms' ), '.hpf-cya__list' ), array( __( 'Answers summary: one answer', 'holmesportfolio-forms' ), '.hpf-cya__row' ), array( __( 'Answers summary: Go to buttons', 'holmesportfolio-forms' ), '.hpf-cya__goto' ), array( __( 'Text inside the thank-you message', 'holmesportfolio-forms' ), '.hpf-panel__body' ), ); echo '
    ' . esc_html__( 'Class names you can style', 'holmesportfolio-forms' ) . ''; echo '

    ' . esc_html__( 'These names change with the style mode above:', 'holmesportfolio-forms' ) . '

    '; echo ''; foreach ( $mode_classes as $row ) { echo ''; } echo '
    ' . esc_html__( 'Part', 'holmesportfolio-forms' ) . 'GOV.UKBootstrap' . esc_html__( 'Plain / None', 'holmesportfolio-forms' ) . '
    ' . esc_html( $row[0] ) . '' . esc_html( $row[1] ) . '' . esc_html( $row[2] ) . '' . esc_html( $row[3] ) . '
    '; echo '

    ' . esc_html__( 'These names are the same in every style mode:', 'holmesportfolio-forms' ) . '

    '; echo ''; foreach ( $shared_classes as $row ) { echo ''; } echo '
    ' . esc_html__( 'Part', 'holmesportfolio-forms' ) . '' . esc_html__( 'Class name', 'holmesportfolio-forms' ) . '
    ' . esc_html( $row[0] ) . '' . esc_html( $row[1] ) . '
    '; echo '

    ' . esc_html__( 'For example, a pale yellow background on the tick boxes and radio buttons in GOV.UK mode:', 'holmesportfolio-forms' ) . '

    '; echo '
    .hpf-form .govuk-radios__label::before, .hpf-form .govuk-checkboxes__label::before { background: #ffffe0; }
    '; echo '

    ' . esc_html__( 'In GOV.UK mode, the white background on radio buttons and tick boxes comes from that same pair of class names. Target them if your theme needs a different colour.', 'holmesportfolio-forms' ) . '

    '; echo '

    ' . esc_html__( 'The thank-you message has a different class name in each style mode. To restyle it everywhere, list all three, as this example does. It gives the message a light grey background, a dark border, dark text and blue links:', 'holmesportfolio-forms' ) . '

    '; $panel_css = implode( "\n", array( '.hpf-form .govuk-panel--confirmation, .hpf-form .alert-success, .hpf-form .hpf-panel--confirmation {', ' background: #f3f2f1;', ' border: 2px solid #0b0c0c;', '}', '.hpf-form .hpf-panel__body, .hpf-form .hpf-panel__body * {', ' color: #0b0c0c !important;', ' -webkit-text-fill-color: #0b0c0c !important;', '}', '.hpf-form .hpf-panel__body a {', ' color: #1a65a6 !important;', ' -webkit-text-fill-color: #1a65a6 !important;', '}', ) ); echo '
    ' . esc_html( $panel_css ) . '
    '; echo '

    ' . esc_html__( 'In GOV.UK, Plain and None modes, the message sets its white text with both color and -webkit-text-fill-color. Set both in your own CSS too, or the text can stay white.', 'holmesportfolio-forms' ) . '

    '; echo '

    ' . esc_html__( 'Additional CSS belongs to your theme. If you switch theme, add your rules again under the new one.', 'holmesportfolio-forms' ) . '

    '; echo '

    ' . esc_html__( 'You rarely need !important. A rule that starts with .hpf-form is more specific than most of the plugin\'s own styles, so it usually wins. Add !important only when the rule you want to beat uses it too. Many themes set text and link colours with !important, so the thank-you example uses it as well.', 'holmesportfolio-forms' ) . '

    '; echo '

    ' . esc_html__( 'Some rules already use !important, so a normal rule can\'t change them. In GOV.UK mode, that includes the helper classes whose names start govuk-!-, such as spacing, text size and width. In every mode, it includes the text colour of links styled as buttons. To change one of these, add !important to your rule too.', 'holmesportfolio-forms' ) . '

    '; echo '

    ' . esc_html__( 'Don\'t override anything that\'s hidden on purpose. Screen-reader text (.govuk-visually-hidden, .visually-hidden and .hpf-visually-hidden) must stay hidden on screen and readable by screen readers. The spam trap (.hpf-hp) must stay hidden too.', 'holmesportfolio-forms' ) . '

    '; echo wp_kses( '

    ' . esc_html__( 'Some field types have extra class names of their own.', 'holmesportfolio-forms' ) . self::guide_link_html( 'css-hooks' ) . '

    ', HP_Forms_Kses::admin() ); echo '
    '; } /** @param string $anchor Guide anchor id, without the '#'. */ /** @param string $anchor A section id in the guide, or a full address when an add-on's own guide holds the section. */ public static function guide_link_html( $anchor ) { $url = 0 === strpos( (string) $anchor, 'http' ) ? (string) $anchor : self::guide_url() . '#' . $anchor; return sprintf( ' %s%s', esc_url( $url ), esc_html__( 'Read more in the guide', 'holmesportfolio-forms' ), self::new_tab_hint() ); } public static function guide_url() { return class_exists( 'HP_Forms_Guide' ) ? HP_Forms_Guide::guide_file_url() : home_url( '/holmesforms-guide/' ); } /** @return array */ public static function help_strings() { return array( 'more' => __( 'Read more in the guide', 'holmesportfolio-forms' ), /* translators: %s: the setting or field name */ 'named' => __( '%s help', 'holmesportfolio-forms' ), 'plain' => __( 'What is this?', 'holmesportfolio-forms' ), ); } /** @return array */ public static function settings_help_map() { return (array) apply_filters( 'holmesforms_settings_help_map', array( 'Default style mode' => array( 'settings-style', __( "The look every form on your site uses. Pick one of:\n- GOV.UK Design System, which the UK government uses for its own online services. It's the default and the recommended choice.\n- Bootstrap\n- Plain\n- None (your theme's own styles)\nThere's no per-form override.", 'holmesportfolio-forms' ) ), 'Accent colour (Plain style)' => array( 'settings-accent', __( "The colour of buttons, links and highlights on Plain-style forms.\n- Leave it empty for the default blue.\n- It needs a contrast of 4.5 to 1 against white. A colour that is too light is not saved.", 'holmesportfolio-forms' ) ), 'Styling with your own CSS' => array( 'settings-css', __( 'There\'s no CSS box in the plugin. Add CSS under Appearance, Customize, Additional CSS, and start each rule with .hpf-form so it only touches the forms. The class names are listed below.', 'holmesportfolio-forms' ) ), 'Store submissions' => array( 'settings-store', __( "Choose which forms keep their entries:\n- every form\n- only the forms you pick\n- none\nNotification emails still send whichever you choose. This only decides whether a copy stays in the database.\nEntries caught by the hidden spam trap go to Spam, so staff can rescue a real person.", 'holmesportfolio-forms' ) ), 'Which forms to store' => array( 'settings-store', __( 'Shows when storage is set to "Choose per form". Tick the forms whose entries you want to keep.', 'holmesportfolio-forms' ) ), 'Storage used' => array( 'settings-storage-usage', __( 'A read-only breakdown of what the plugin holds in the database. It shows a total and what the next clean-up will remove.', 'holmesportfolio-forms' ) ), 'Delete entries after (days)' => array( 'settings-retention', __( "How long entries are kept.\n- Older entries are deleted once a day.\n- The default is 365 days. You can pick anything from 30 to 730.\n- There's no \"keep forever\" option.", 'holmesportfolio-forms' ) ), 'Per-form retention' => array( 'settings-retention', __( 'Sets a different retention period for one form. It uses the same 30 to 730 day range.', 'holmesportfolio-forms' ) ), 'Record the submitter IP' => array( 'settings-ip', __( 'Off by default. An IP address is personal data, so only turn this on if your privacy notice covers it.', 'holmesportfolio-forms' ) ), 'Encryption at rest' => array( 'encryption', __( "Read-only status. Stored answers are encrypted once you set a key in one of these places:\n- wp-config.php\n- a filter\n- an environment variable", 'holmesportfolio-forms' ) ), 'Reference prefix' => array( 'settings-ref', __( "The letters before the number in a submission reference. The default is ENQ.\n- It applies to new submissions only.\n- A form can set its own prefix on its Setup tab.", 'holmesportfolio-forms' ) ), 'When emails are sent' => array( 'settings-send', __( "Emails go out straight after the visitor sees the thank-you message, so they don't wait. - Your server sends them, even if the visitor closes the page. - A failed email is tried 3 times, then admins see a notice. - If your host blocks this, the plugin sends before the thank-you message instead.", 'holmesportfolio-forms' ) ), 'Sending limit' => array( 'settings-mail-cap', __( "The most form emails this site sends in an hour and in a day. - Over the limit, form emails wait in the send queue and go out once there's room. Waiting doesn't use up a try. - An email still waiting near the end of the keep time is sent anyway, so none are lost. - Blank means no limit. - Ask your host what its limit is.", 'holmesportfolio-forms' ) ), 'Default from address' => array( 'settings-email', __( "The \"from\" address on every email the plugin sends, so a private inbox isn't shown as the sender.\n- Use a no-reply address on your own domain.\n- An email action can use a different one.\n- Replies go to the form's Group inbox.", 'holmesportfolio-forms' ) ), 'Email look' => array( 'email-look', __( "Colours, font, text size and width for every email the plugin sends.\n- Blank means the default.\n- Text, headings and links need a contrast of at least 4.5 to 1 against the colour behind them. A colour that fails isn't saved, and the old one stays.\n- Pictures, wording and other HTML go in the header, footer and each form's email actions.\n- A developer can add more CSS with the holmesforms_email_css filter. It's added after these settings, so it can override them.", 'holmesportfolio-forms' ) ), 'Header on every email' => array( 'settings-email', __( 'HTML at the top of every notification email, such as a logo or a standard greeting.', 'holmesportfolio-forms' ) ), 'Footer on every email' => array( 'settings-email', __( 'HTML at the bottom of every notification email, such as contact details or a legal line.', 'holmesportfolio-forms' ) ), 'Additional challenge' => array( 'spam', __( "Choose None, Cloudflare Turnstile or Google reCAPTCHA v2. The GOV.UK Service Manual advises against a CAPTCHA test.\n- Both read information from your visitors' browsers and can set cookies. reCAPTCHA sets one called _GRECAPTCHA. The plugin sets no cookies of its own.", 'holmesportfolio-forms' ) ), 'Challenge keys' => array( 'spam', __( "The site key and secret key for the challenge you picked.\n- Secret keys are masked.\n- Each provider keeps its own keys when you switch.\n- To remove a provider's keys, pick it, empty its site key box and save.",'holmesportfolio-forms' ) ), 'Visitor address' => array( 'settings-visitor-ip', __( 'How the site finds a visitor\'s address for the spam limits. Only choose Cloudflare or a proxy if one really sits in front of your site.', 'holmesportfolio-forms' ) ), 'Unusual activity alert' => array( 'settings-flood', __( "How many entries one form can get in an hour before the site admin is warned.\n- The warning is one email a day for that form.\n- Entries are still accepted.\n- 0 turns the alert off.", 'holmesportfolio-forms' ) ), 'Styling' => array( 'settings-style', __( 'How every form looks: the GOV.UK Design System (recommended), Bootstrap, plain or your theme’s own styles, plus extra CSS.', 'holmesportfolio-forms' ) ), 'Email appearance' => array( 'settings-email', __( 'The sender address, the look, and the header and footer wrapped around every email the plugin sends.', 'holmesportfolio-forms' ) ), 'Data & privacy' => array( 'settings-store', __( 'Which entries are kept, for how long, and what else is recorded about each one.', 'holmesportfolio-forms' ) ), 'Spam protection' => array( 'spam', __( 'The checks that keep out spam: the hidden trap, the time check, rate limits and an optional challenge.', 'holmesportfolio-forms' ) ), 'Blocked words' => array( 'blocked-words', __( "Checks text input and text area answers for swear words and slurs.\n- Whole words only, so place names like Scunthorpe are safe.\n- Simple disguises like sh1t are still caught.\n- Email and web addresses are skipped.", 'holmesportfolio-forms' ) ), 'Check answers' => array( 'blocked-words', __( "Turn the check on or off, and choose which lists to use.\n- The built-in list covers strong swear words and slurs.\n- WordPress's Disallowed Comment Keys list is under Settings, Discussion.", 'holmesportfolio-forms' ) ), 'Your own blocked words' => array( 'blocked-words', __( 'Extra words or phrases to block, one per line. Each is matched as a whole word, so "ass" does not catch "class".', 'holmesportfolio-forms' ) ), 'When an answer has a blocked word' => array( 'blocked-words', __( "What happens when an answer has a blocked word.\n- Ask them to change it (recommended): the form shows an error at that question and can't be sent until it's reworded.\n- Accept and highlight: the entry arrives as normal with the words marked for staff.\n- Save as spam: it goes to the Spam list and no emails are sent.", 'holmesportfolio-forms' ) ), 'Message to the visitor' => array( 'blocked-words', /* translators: %s is typed as it is; it is not replaced here. */ __( 'The error shown at the question when an answer is refused. %s is replaced with the words found.', 'holmesportfolio-forms' ) ), 'Different for a form' => array( 'blocked-words', __( 'Give one form its own rule, or turn the check off for it. Useful on a complaints form, where people may need to quote what was said to them.', 'holmesportfolio-forms' ) ), 'Visitors without JavaScript' => array( 'no-js', __( 'What people see when their browser has JavaScript turned off. By default the form is hidden and they\'re asked to turn JavaScript on. GOV.UK\'s guidance for government services recommends letting them fill it in. If you do, several features stop working.', 'holmesportfolio-forms' ) ), 'Working days' => array( 'settings-bank-holidays', __( 'The days that count when a deadline is set in working days, like 20 working days. Monday to Friday by default.', 'holmesportfolio-forms' ) ), 'Bank holidays to leave out' => array( 'settings-bank-holidays', __( 'Bank holidays skipped when counting working days. Pick one part of the UK, all of them, or none. The list comes from gov.uk. A form can use a different choice on its builder Setup tab.', 'holmesportfolio-forms' ) ), 'Keep the list up to date' => array( 'settings-bank-holidays', __( "The plugin ships with GOV.UK's bank holiday list, and each update brings a newer copy.\n- Turn this on to also fetch the list from gov.uk once a week.\n- Only the public list is fetched. No personal data is sent.", 'holmesportfolio-forms' ) ), 'Restore starter content' => array( 'settings-starter', __( 'Adds back any missing starter block, and the starter forms you tick. Tick a form that is already here to reset it to the built-in version. Anything you have locked is skipped.', 'holmesportfolio-forms' ) ), 'A basic example' => array( 'settings-email', __( 'Fills the header and footer boxes above with your site name at the top and a short line at the bottom. Nothing is saved until you press Save changes.', 'holmesportfolio-forms' ) ), 'Test your emails' => array( 'emails', __( 'Sends a test email so you can check it arrives and doesn\'t land in spam. If it never arrives, set up a mail plugin that sends through a mailbox on your own domain, using the Simple Mail Transfer Protocol (SMTP).', 'holmesportfolio-forms' ) ), 'Address lookup' => array( 'set-address-lookup', __( 'Adds a Find address box to address fields. The visitor types a UK postcode and the town and county are filled in. The postcode goes to api.postcodes.io only when they press the button.', 'holmesportfolio-forms' ) ), 'Show on address fields' => array( 'set-address-lookup', __( 'Adds the Find address box to every address field on your forms.', 'holmesportfolio-forms' ) ), 'Daily limit' => array( 'set-address-lookup', __( 'The most lookups a day. 0 means no limit. Over the limit, visitors type the address themselves.', 'holmesportfolio-forms' ) ), 'Protect the sign-in' => array( 'protect', __( 'How to protect the admin side outside this plugin: lock wp-admin to your own network in .htaccess, and ask staff for a second step when they sign in.', 'holmesportfolio-forms' ) ), 'Automatic backups' => array( 'auto-backup', __( 'Saves your forms, settings and saved blocks once a week and keeps the last 8. A backup never holds submissions or uploads, but it can hold email addresses and keys, so keep downloads private.', 'holmesportfolio-forms' ) ), 'Import' => array( 'import-export', __( 'Adds forms, and if you tick them, settings and saved blocks, from a backup or an exported form. Forms already here are left alone unless you tick Replace.', 'holmesportfolio-forms' ) ), ) ); } public function email_intro() { echo wp_kses( '

    ' . esc_html__( 'These settings wrap every HTML email this plugin sends: the look, then a header and footer. Email apps vary, so for anything in the header and footer that must hold everywhere, put the style on the HTML itself. Plain-text emails get the header and footer as plain text.', 'holmesportfolio-forms' ) . self::guide_link_html( 'emails' ) . '

    ', HP_Forms_Kses::admin() ); } public function field_send_background() { $value = '0' === (string) $this->get_setting( 'send_background', '1' ) ? '0' : '1'; echo '
    ' . esc_html__( 'When emails are sent', 'holmesportfolio-forms' ) . ''; self::render_setting_radios( 'send_background', array( '1' => __( 'Just after the visitor sees the thank-you message (default, faster for them)', 'holmesportfolio-forms' ), '0' => __( 'Before the thank-you message shows', 'holmesportfolio-forms' ), ), $value ); echo '

    ' . esc_html__( 'Emails, webhooks and mailing list updates are sent by your server, so closing the page does not stop them. If your host blocks this, the plugin notices and sends before the thank-you message instead. Choose the second option only if your email plugin needs it, or while you look into an email problem.', 'holmesportfolio-forms' ) . '

    '; } public function field_mail_cap() { $boxes = array( 'mail_cap_hour' => __( 'Most emails an hour', 'holmesportfolio-forms' ), 'mail_cap_day' => __( 'Most emails a day', 'holmesportfolio-forms' ), ); echo '
    ' . esc_html__( 'Sending limit', 'holmesportfolio-forms' ) . ''; foreach ( $boxes as $key => $label ) { printf( '

    ', esc_attr( str_replace( '_', '-', $key ) ), esc_html( $label ), esc_attr( self::SETTINGS ), esc_attr( $key ), esc_attr( (string) $this->get_setting( $key, '' ) ) ); } echo '

    ' . esc_html__( 'Many hosts limit how many emails a site can send, for example 50 an hour or 100 a day. Ask your host for yours and enter it here. Leave a box blank for no limit. When a limit is reached, form emails wait in the send queue without using up any of their 3 tries. They go out once the hour or day has room again. Only this plugin\'s form emails are counted here. Other plugins\' emails still count towards your host\'s limit, so leave some room. For reliable sending, use an SMTP plugin that sends through a real email service.', 'holmesportfolio-forms' ) . '

    '; } public function field_from_email() { $from_email = (string) $this->get_setting( 'from_email' ); $admin_email = sanitize_email( get_option( 'admin_email' ) ); printf( '

    %3$s

    %4$s

    ', esc_attr( self::SETTINGS ), esc_attr( $from_email ), /* translators: %s: an example email address */ esc_html( sprintf( __( 'For example, %s', 'holmesportfolio-forms' ), 'noreply@' . wp_parse_url( home_url(), PHP_URL_HOST ) ) ), esc_html( sprintf( /* translators: %s: the WordPress admin email address */ __( 'The "from" address on every email this plugin sends, so a private inbox isn\'t shown as the sender. Use an address on your own domain that your site is allowed to send as. Your domain\'s Sender Policy Framework (SPF) and DomainKeys Identified Mail (DKIM) records decide this. Leave it blank to use the WordPress admin address (%s). An email action can set its own. If this is a no-reply address, set a Group inbox on each form so replies still reach you.', 'holmesportfolio-forms' ), $admin_email ) ) ); } public function field_email_look() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-mailer.php'; $defaults = HP_Forms_Mailer::LOOK_DEFAULTS; $labels = self::email_look_labels(); $fonts = array( 'arial' => 'Arial', 'verdana' => 'Verdana', 'trebuchet' => 'Trebuchet MS', 'georgia' => 'Georgia', 'times' => 'Times New Roman', 'system' => __( 'The reader\'s own system font', 'holmesportfolio-forms' ), ); $hints = array( 'email_page_colour' => __( 'Behind the panel.', 'holmesportfolio-forms' ), 'email_panel_colour' => __( 'Behind the message.', 'holmesportfolio-forms' ), 'email_footer_colour' => __( 'Blank uses the panel colour with a thin line above.', 'holmesportfolio-forms' ), 'email_footer_text' => __( 'Blank uses the text colour.', 'holmesportfolio-forms' ), ); $preview = array( 'page' => $defaults['email_page_colour'], 'panel' => $defaults['email_panel_colour'], 'text' => $defaults['email_text_colour'], 'heading' => $defaults['email_heading_colour'], 'link' => $defaults['email_link_colour'], 'font' => HP_Forms_Mailer::LOOK_FONTS[ $defaults['email_font'] ], ); echo wp_kses( '

    ' . esc_html__( 'Colours, font and size for every email this plugin sends. Blank means the default. Text, headings and links need a contrast of at least 4.5 to 1 against the colour behind them. A colour that fails isn\'t saved, and the old one stays. Pictures, wording and other HTML go in the header, footer and each form\'s email actions.', 'holmesportfolio-forms' ) . self::guide_link_html( 'email-look' ) . '

    ', HP_Forms_Kses::admin() ); echo '
    '; echo '
    '; foreach ( $labels as $key => $label ) { $id = 'hpf-' . str_replace( '_', '-', $key ); $value = (string) $this->get_setting( $key, '' ); if ( 'email_font' === $key ) { $value = isset( HP_Forms_Mailer::LOOK_FONTS[ $value ] ) ? $value : ''; } elseif ( is_int( $defaults[ $key ] ) ) { $value = absint( $value ) ? (string) absint( $value ) : ''; } else { $value = (string) sanitize_hex_color( $value ); } echo '
    '; echo ''; if ( 'email_font' === $key ) { echo ''; } elseif ( is_int( $defaults[ $key ] ) ) { $range = 'email_width' === $key ? HP_Forms_Mailer::LOOK_WIDTH_RANGE : HP_Forms_Mailer::LOOK_FONT_SIZE_RANGE; printf( ' %8$s', esc_attr( $id ), esc_attr( self::SETTINGS ), esc_attr( $key ), esc_attr( $value ), (int) $range[0], (int) $range[1], (int) $defaults[ $key ], /* translators: 1: smallest value, 2: largest value */ esc_html( sprintf( __( 'pixels, %1$d to %2$d', 'holmesportfolio-forms' ), $range[0], $range[1] ) ) ); } else { printf( ' ', esc_attr( $id ), esc_attr( self::SETTINGS ), esc_attr( $key ), esc_attr( $value ), esc_attr( '' !== $defaults[ $key ] ? $defaults[ $key ] : __( 'blank', 'holmesportfolio-forms' ) ), esc_attr( '' !== $value ? $value : ( '' !== $defaults[ $key ] ? $defaults[ $key ] : $defaults['email_panel_colour'] ) ), /* translators: %s: the setting name */ esc_attr( sprintf( __( 'Pick the %s', 'holmesportfolio-forms' ), strtolower( $label ) ) ) ); } if ( isset( $hints[ $key ] ) ) { echo '' . esc_html( $hints[ $key ] ) . ''; } echo '
    '; } echo '
    '; echo '

    ' . esc_html__( 'Clears every box above. Nothing is saved until you press Save changes.', 'holmesportfolio-forms' ) . '

    '; echo ''; echo '

    ' . esc_html__( 'The preview is a rough guide. Use Send test email at the bottom of this tab to see the real thing in your inbox.', 'holmesportfolio-forms' ) . '

    '; echo '
    '; } public function field_email_header() { printf( '

    %s

    ', esc_attr( self::SETTINGS ), esc_textarea( $this->get_setting( 'email_header' ) ), esc_html__( 'HTML added above the message body, such as a logo. Use a full https:// image URL.', 'holmesportfolio-forms' ) ); } public function field_email_example() { $site = trim( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ); $header = '

    ' . esc_html( '' !== $site ? $site : __( 'Your organisation name', 'holmesportfolio-forms' ) ) . '

    '; $footer = '

    ' . esc_html__( 'Sent from the forms on our website.', 'holmesportfolio-forms' ) . '

    '; printf( '

    %s

    ', esc_attr( $header ), esc_attr( $footer ), esc_attr__( 'Replace what is there?', 'holmesportfolio-forms' ), esc_attr__( 'This replaces what is in the header and footer boxes. Nothing is saved until you press Save changes.', 'holmesportfolio-forms' ), esc_attr__( 'Replace', 'holmesportfolio-forms' ), esc_html__( 'Fill in the basic example', 'holmesportfolio-forms' ), esc_html__( 'Fills the header and footer boxes above: your site name at the top and a short line at the bottom. Change the words to yours, then press Save changes. Nothing is saved until you do.', 'holmesportfolio-forms' ) ); } public function field_email_footer() { printf( '

    %s

    ', esc_attr( self::SETTINGS ), esc_textarea( $this->get_setting( 'email_footer' ) ), esc_html__( 'HTML added below the message body, such as an address or an unsubscribe note.', 'holmesportfolio-forms' ) ); } public function field_store_mode() { $value = $this->get_setting( 'store_mode', 'all' ); $options = array( 'all' => __( 'Save every form’s submissions (default)', 'holmesportfolio-forms' ), 'per_form' => __( 'Choose per form: see the list below', 'holmesportfolio-forms' ), 'never' => __( 'Never save any submissions', 'holmesportfolio-forms' ), ); self::render_setting_select( 'hpf-store-mode', 'store_mode', $options, $value ); echo '

    ' . esc_html__( 'Emails still send in every mode. This only controls whether a copy of the answers is kept in the database. With “Never save” the retention setting below does nothing.', 'holmesportfolio-forms' ) . '

    '; } public function field_store_forms() { $stored_forms = (array) $this->get_setting( 'store_forms', array() ); $forms = get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); if ( ! $forms ) { echo '

    ' . esc_html__( 'No forms yet.', 'holmesportfolio-forms' ) . '

    '; return; } echo '
    ' . esc_html( sprintf( /* translators: %d: form count */ _n( '%d form', '%d forms', count( $forms ), 'holmesportfolio-forms' ), count( $forms ) ) ) . ''; echo '

    ' . esc_html__( 'Only used when “Choose per form” is selected above. A form with no choice is saved.', 'holmesportfolio-forms' ) . '

    '; echo '
      '; foreach ( $forms as $form ) { $is_stored = ! isset( $stored_forms[ $form->ID ] ) || ! empty( $stored_forms[ $form->ID ] ); printf( '
    • ', esc_attr( self::SETTINGS ), (int) $form->ID, checked( $is_stored, true, false ), esc_html( $form->post_title ? $form->post_title : sprintf( /* translators: %d: form id */ __( 'Form %d', 'holmesportfolio-forms' ), $form->ID ) ) ); } echo '
    '; } public function field_storage_usage() { global $wpdb; $rows = get_transient( 'holmesforms_storage_usage' ); if ( ! is_array( $rows ) ) { $rows = array(); $submissions_table = HP_Forms_Storage::table(); $entry_count = (int) HP_Forms_Storage::count(); $table_bytes = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT data_length + index_length FROM information_schema.TABLES WHERE table_schema = %s AND table_name = %s', DB_NAME, $submissions_table ) ); // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables $rows[] = array( /* translators: %s: number of entries */ 'label' => sprintf( _n( 'Stored entries (%s row)', 'Stored entries (%s rows)', $entry_count, 'holmesportfolio-forms' ), number_format_i18n( $entry_count ) ), 'bytes' => $table_bytes, ); // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables, cached below $meta_bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(pm.meta_value)) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = '" . esc_sql( HOLMESFORMS_POST_TYPE ) . "' AND pm.meta_key LIKE '\_holmesforms\_%'" ); // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables, cached below $revision_bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(pm.meta_value)) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = '" . esc_sql( HOLMESFORMS_POST_TYPE ) . "' AND pm.meta_key = '" . esc_sql( self::REVISION_META ) . "'" ); // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables $rows[] = array( 'label' => __( 'Form definitions', 'holmesportfolio-forms' ), 'bytes' => max( 0, $meta_bytes - $revision_bytes ), ); $rows[] = array( 'label' => __( 'Version history (last 30 saves per form)', 'holmesportfolio-forms' ), 'bytes' => $revision_bytes, ); /** @param array $add_on_tables An add-on's tables: suffix => { label, count (show the row count), drafts (rows are saved drafts) }. */ $add_on_tables = (array) apply_filters( 'holmesforms_storage_size_tables', array() ); foreach ( $add_on_tables as $suffix => $table_info ) { $suffix = sanitize_key( $suffix ); $table_info = wp_parse_args( (array) $table_info, array( 'label' => $suffix, 'count' => false, 'drafts' => false, ) ); $table = $wpdb->prefix . $suffix; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- checks the table exists; the figures are cached below if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { continue; } $row_count = $table_info['count'] ? (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) : 0; // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables $label = $table_info['count'] /* translators: 1: table description, 2: row count */ ? sprintf( _n( '%1$s (%2$s row)', '%1$s (%2$s rows)', $row_count, 'holmesportfolio-forms' ), $table_info['label'], number_format_i18n( $row_count ) ) : $table_info['label']; $rows[] = array( 'label' => $label, 'bytes' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT data_length + index_length FROM information_schema.TABLES WHERE table_schema = %s AND table_name = %s', DB_NAME, $table ) ), // phpcs:ignore WordPress.DB -- storage figures from the plugin's own tables 'drafts' => ! empty( $table_info['drafts'] ) ? $row_count : null, ); } set_transient( 'holmesforms_storage_usage', $rows, self::STORAGE_USAGE_CACHE_SECONDS ); } $total = 0; $drafts = 0; foreach ( $rows as $row ) { $total += (int) $row['bytes']; if ( isset( $row['drafts'] ) ) { $drafts = (int) $row['drafts']; } } if ( $drafts > 0 ) { echo '

    '; printf( esc_html( /* translators: %s: number of saved drafts */ _n( 'Heads up: %s visitor has used "Save and come back later". Their saved draft is held here, with the answers so far and the email the resume link went to. It\'s a part-finished form, not spam or a queued email. Saved drafts clear themselves when the resume link expires (set on Settings → Workflow).', 'Heads up: %s visitors have used "Save and come back later". Their saved drafts are held here, with the answers so far and the email each resume link went to. They\'re part-finished forms, not spam or queued emails. Saved drafts clear themselves when the resume link expires (set on Settings → Workflow).', $drafts, 'holmesportfolio-forms' ) ), esc_html( number_format_i18n( $drafts ) ) ); echo '

    '; } echo ''; foreach ( $rows as $row ) { printf( '', esc_html( $row['label'] ), esc_html( $row['bytes'] > 0 ? size_format( $row['bytes'], 1 ) : __( 'None', 'holmesportfolio-forms' ) ) ); } printf( '', esc_html__( 'Total', 'holmesportfolio-forms' ), esc_html( size_format( $total, 1 ) ) ); echo '
    %s%s
    %s%s
    '; $due_count = (int) HP_Forms_Storage::count_expired(); $message = $due_count > 0 /* translators: %s: number of entries */ ? sprintf( _n( 'The daily clean-up will remove %s entry that is now past its retention period.', 'The daily clean-up will remove %s entries that are now past their retention period.', $due_count, 'holmesportfolio-forms' ), number_format_i18n( $due_count ) ) : __( 'No stored entries are currently past their retention period.', 'holmesportfolio-forms' ); echo '

    ' . esc_html( $message ) . ' '; esc_html_e( 'Figures refresh a few minutes after a change. Export what you need and delete the rest to keep this down.', 'holmesportfolio-forms' ); echo '

    '; } public function field_storage_diagnostics() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-storage.php'; $stats = get_option( HP_Forms_Storage::STORAGE_DIAGNOSTICS_OPTION ); if ( ! is_array( $stats ) || empty( $stats['computed_at'] ) ) { echo '

    ' . esc_html__( 'Not worked out yet. This is done once a day by the same clean-up that removes entries past their retention period, so it will appear the day after this plugin was activated.', 'holmesportfolio-forms' ) . '

    '; return; } printf( '

    %s

    ', esc_html( sprintf( /* translators: %s: how long ago this was worked out, e.g. "3 hours" */ __( 'Worked out %s ago. Reading through every entry to find the largest ones takes too long to do while you wait, so this is only ever done once a day.', 'holmesportfolio-forms' ), human_time_diff( (int) $stats['computed_at'] ) ) ) ); if ( empty( $stats['by_form'] ) && empty( $stats['largest'] ) ) { echo '

    ' . esc_html__( 'Nothing stored yet.', 'holmesportfolio-forms' ) . '

    '; return; } if ( ! empty( $stats['by_form'] ) ) { echo '

    ' . esc_html__( 'Forms with the most stored data', 'holmesportfolio-forms' ) . '

    '; echo ''; foreach ( $stats['by_form'] as $row ) { $title = '' !== $row['title'] ? $row['title'] : sprintf( /* translators: %d: form id */ __( 'Form #%d (deleted)', 'holmesportfolio-forms' ), $row['form_id'] ); printf( '', esc_url( admin_url( 'admin.php?page=holmesforms-entries&form_id=' . (int) $row['form_id'] ) ), esc_html( $title ), esc_html( number_format_i18n( $row['n'] ) ), esc_html( size_format( $row['bytes'], 1 ) ) ); } echo '
    ' . esc_html__( 'Form', 'holmesportfolio-forms' ) . '' . esc_html__( 'Entries', 'holmesportfolio-forms' ) . '' . esc_html__( 'Data', 'holmesportfolio-forms' ) . '
    %s%s%s
    '; } if ( ! empty( $stats['largest'] ) ) { echo '

    ' . esc_html__( 'The largest individual entries', 'holmesportfolio-forms' ) . '

    '; echo ''; foreach ( $stats['largest'] as $row ) { $title = '' !== $row['title'] ? $row['title'] : sprintf( /* translators: %d: form id */ __( 'Form #%d (deleted)', 'holmesportfolio-forms' ), $row['form_id'] ); printf( '', esc_url( admin_url( 'admin.php?page=holmesforms-entries&view=' . (int) $row['id'] ) ), esc_html( $title ), esc_html( holmesforms_date( mysql2date( 'U', $row['created'], false ) ) ), esc_html( size_format( $row['bytes'], 1 ) ) ); } echo '
    ' . esc_html__( 'Form', 'holmesportfolio-forms' ) . '' . esc_html__( 'Submitted', 'holmesportfolio-forms' ) . '' . esc_html__( 'Size', 'holmesportfolio-forms' ) . '
    %s%s%s
    '; } echo '

    ' . esc_html__( 'Uploaded files and photos aren\'t kept in entries, so they don\'t count towards these sizes. Open a large entry to see what\'s taking the space, such as long answers, a group filled in many times or a drawn signature.', 'holmesportfolio-forms' ) . '

    '; } public function field_retention() { if ( 'never' === $this->get_setting( 'store_mode', 'all' ) ) { echo '

    ' . esc_html__( 'Not used. No submissions are stored. Change “Store submissions” above to set a retention period.', 'holmesportfolio-forms' ) . '

    '; $this->hide_row_when_store_mode_never(); return; } $bounds = HP_Forms_Storage::retention_bounds(); $days = HP_Forms_Storage::retention_days_for( 0 ); printf( ' %5$s', (int) $bounds['min'], (int) $bounds['max'], esc_attr( self::SETTINGS ), (int) $days, esc_html( sprintf( /* translators: 1: minimum days, 2: maximum days */ __( 'A daily job removes stored entries older than this. Allowed range %1$d–%2$d days. Retention always applies. There is no "keep forever".', 'holmesportfolio-forms' ), (int) $bounds['min'], (int) $bounds['max'] ) ) ); echo '

    ' . wp_kses_post( sprintf( /* translators: %s: link to the ICO storage limitation guidance */ __( 'UK data protection law says personal data must not be kept longer than you need it for the purpose you collected it. There is no fixed number in law. %s explains how to decide. A developer can change the allowed range with a filter if you have a documented reason. This is guidance, not legal advice.', 'holmesportfolio-forms' ), '' . esc_html__( 'The Information Commission’s Office (ICO) guidance on storage limitation', 'holmesportfolio-forms' ) . self::new_tab_hint() . '' ) ) . '

    '; $this->hide_row_when_store_mode_never(); } private function hide_row_when_store_mode_never() { echo ''; } /** Blank follows the global value. */ public function field_retention_overrides() { $mode = $this->get_setting( 'store_mode', 'all' ); if ( 'never' === $mode ) { echo '

    ' . esc_html__( 'Not used. No submissions are stored.', 'holmesportfolio-forms' ) . '

    '; return; } $bounds = HP_Forms_Storage::retention_bounds(); $overrides = (array) $this->get_setting( 'retention_overrides', array() ); $forms = get_posts( array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'any', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, 'fields' => 'ids', ) ); if ( ! $forms ) { echo '

    ' . esc_html__( 'Create a form first.', 'holmesportfolio-forms' ) . '

    '; return; } if ( 'per_form' === $mode ) { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-submission.php'; $forms = array_values( array_filter( $forms, static function ( $form_id ) { return HP_Forms_Submission::should_store( (int) $form_id ); } ) ); if ( ! $forms ) { echo '

    ' . esc_html__( 'No forms are set to store submissions, so there is nothing to age out.', 'holmesportfolio-forms' ) . '

    '; return; } } echo '

    ' . esc_html( sprintf( /* translators: 1: minimum days, 2: maximum days, 3: global value */ __( 'Leave a box blank to use the value above (%3$d days). An override outside %1$d–%2$d days is brought back into that range.', 'holmesportfolio-forms' ), (int) $bounds['min'], (int) $bounds['max'], (int) HP_Forms_Storage::retention_days_for( 0 ) ) ) . '

    '; echo ''; foreach ( $forms as $form_id ) { $form_id = (int) $form_id; $override_days = isset( $overrides[ $form_id ] ) ? (int) $overrides[ $form_id ] : ''; printf( '', esc_html( get_the_title( $form_id ) ? get_the_title( $form_id ) : ( '#' . $form_id ) ), (int) $bounds['min'], (int) $bounds['max'], esc_attr( self::SETTINGS ), absint( $form_id ), esc_attr( (string) $override_days ), esc_html__( 'days', 'holmesportfolio-forms' ) ); } echo '
    %7$s
    '; } public function field_store_ip() { if ( 'never' === $this->get_setting( 'store_mode', 'all' ) ) { echo '

    ' . esc_html__( 'Not used. No submissions are stored, so there is nothing to attach an IP address to.', 'holmesportfolio-forms' ) . '

    '; $this->hide_row_when_store_mode_never(); return; } printf( '

    %s

    ', esc_attr( self::SETTINGS ), checked( ! empty( $this->get_setting( 'store_ip' ) ), true, false ), esc_html__( 'Save the visitor IP address with each stored entry', 'holmesportfolio-forms' ), esc_html__( 'Off by default, and only kept when submissions are stored at all. An IP address is personal data under UK data protection law. Turn this on only if you have a specific reason, for example fraud investigation, and your privacy notice covers it. This is guidance, not legal advice.', 'holmesportfolio-forms' ) ); $this->hide_row_when_store_mode_never(); } public function field_encryption() { require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; if ( ! HP_Forms_Crypt::is_active() ) { /** @param string $plain_note What is held as plain text while encryption is off; an add-on names its own data. */ $plain_note = (string) apply_filters( 'holmesforms_encryption_plain_note', __( 'Stored answers are held as plain text in the database.', 'holmesportfolio-forms' ) ); echo '

    ' . esc_html__( 'Off.', 'holmesportfolio-forms' ) . ' ' . esc_html( $plain_note ) . '

    '; if ( '' !== HP_Forms_Crypt::source_name() && function_exists( 'sodium_crypto_secretbox' ) ) { echo '

    ' . esc_html__( 'An encryption key is set but it is shorter than 16 characters, so it is ignored and nothing is encrypted. Use a longer key.', 'holmesportfolio-forms' ) . '

    '; } echo '

    ' . esc_html__( 'To encrypt them at rest:', 'holmesportfolio-forms' ) . '

    '; echo wp_kses( self::encryption_key_box_html(), HP_Forms_Kses::admin() ); echo '

    ' . esc_html__( 'Entries saved before you add the key stay unencrypted for now. Once the key is in place, a button appears here to encrypt them.', 'holmesportfolio-forms' ) . '

    '; echo wp_kses( '

    ' . esc_html__( 'To keep the key off the web server entirely (for NHS DSP Toolkit or a strict security review), set an HOLMESFORMS_ENCRYPTION_KEY environment variable instead, or return it from the holmesforms_encryption_key filter in a must-use plugin (fetching it from a secrets manager). The order tried is: filter, then the constant, then the environment variable.', 'holmesportfolio-forms' ) . self::guide_link_html( 'encryption-key-source' ) . '

    ', HP_Forms_Kses::admin() ); return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- the plugin's own table, counts sealed and plain rows $stored_counts = (array) $wpdb->get_row( $wpdb->prepare( "SELECT SUM( data LIKE %s ) sealed, SUM( data NOT LIKE %s AND data <> '' ) plain FROM %i", $wpdb->esc_like( 'hpfenc1:' ) . '%', $wpdb->esc_like( 'hpfenc1:' ) . '%', HP_Forms_Storage::table() ), ARRAY_A ); $plain_count = (int) ( $stored_counts['plain'] ?? 0 ); $sealed_count = (int) ( $stored_counts['sealed'] ?? 0 ); $source = HP_Forms_Crypt::source_name(); $source_label = array( 'filter' => __( 'the holmesforms_encryption_key filter (a secrets manager or similar)', 'holmesportfolio-forms' ), 'env' => __( 'an HOLMESFORMS_ENCRYPTION_KEY environment variable', 'holmesportfolio-forms' ), 'constant' => __( 'the HOLMESFORMS_ENCRYPTION_KEY constant in wp-config.php', 'holmesportfolio-forms' ), ); echo '

    ' . esc_html__( 'On.', 'holmesportfolio-forms' ) . ' ' . esc_html__( 'New entries are sealed with libsodium before they are written, and opened again on read.', 'holmesportfolio-forms' ) . '

    '; if ( ! extension_loaded( 'sodium' ) ) { echo '

    ' . esc_html__( 'Encryption still works, but this server\'s PHP is missing the sodium extension, so opening, listing, searching and exporting entries can be much slower. Ask your host to turn on the PHP sodium extension, which comes as standard with PHP 7.2 and later.', 'holmesportfolio-forms' ) . '

    '; } if ( isset( $source_label[ $source ] ) ) { echo '

    ' . esc_html( sprintf( /* translators: %s: where the key currently comes from */ __( 'Key currently supplied by: %s.', 'holmesportfolio-forms' ), $source_label[ $source ] ) ); if ( 'constant' === $source ) { echo ' ' . esc_html__( 'That sits on the same server as the database it protects. To keep it off the web root instead (for NHS DSP Toolkit or a strict security review), move it to an environment variable, or return it from the holmesforms_encryption_key filter in a must-use plugin (fetching it from a secrets manager). Whichever you set is picked up automatically. Nothing else changes.', 'holmesportfolio-forms' ); } echo wp_kses( self::guide_link_html( 'encryption' ) . '

    ', HP_Forms_Kses::admin() ); } echo '

    ' . esc_html( sprintf( /* translators: 1: encrypted count, 2: plain-text count */ __( 'Stored now: %1$s encrypted, %2$s still in plain text.', 'holmesportfolio-forms' ), number_format_i18n( $sealed_count ), number_format_i18n( $plain_count ) ) ) . '

    '; if ( $plain_count > 0 ) { $url = wp_nonce_url( add_query_arg( 'action', 'holmesforms_encrypt_backfill', admin_url( 'admin-post.php' ) ), 'holmesforms_encrypt_backfill' ); echo '

    ' . esc_html( sprintf( /* translators: %s: number of entries */ _n( 'Encrypt %s entry stored before now', 'Encrypt %s entries stored before now', $plain_count, 'holmesportfolio-forms' ), number_format_i18n( $plain_count ) ) ) . ' ' . esc_html__( 'Runs in batches of about 4,000. Click again if there are more left.', 'holmesportfolio-forms' ) . '

    '; } else { echo '

    ' . esc_html__( 'Every stored entry is encrypted.', 'holmesportfolio-forms' ) . '

    '; } echo '

    ' . esc_html__( 'While encryption is on, the Entries search box scans the most recent few thousand entries rather than all of them.', 'holmesportfolio-forms' ) . '

    '; } public function handle_adopt_key() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_admin_referer( 'holmesforms_adopt_key' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } if ( HP_Forms_Crypt::is_active() ) { HP_Forms_Crypt::adopt_key(); } delete_transient( 'holmesforms_enc_check' ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } public function handle_unsaved_seen() { if ( ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_unsaved_seen' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } delete_option( HP_Forms_Storage::OPTION_UNSAVED ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } /** Re-writes plaintext rows through the encryptor in batches. */ public function handle_encrypt_backfill() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_admin_referer( 'holmesforms_encrypt_backfill' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-crypt.php'; delete_transient( 'holmesforms_enc_check' ); if ( ! HP_Forms_Crypt::can_write() ) { wp_safe_redirect( add_query_arg( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'data', ), admin_url( 'admin.php' ) ) ); exit; } global $wpdb; $table = HP_Forms_Storage::table(); $encrypted = 0; for ( $batch = 0; $batch < self::ENCRYPT_BATCHES; $batch++ ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table, rows still to seal $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, data, context FROM %i WHERE data NOT LIKE %s AND data <> '' LIMIT %d", $table, $wpdb->esc_like( 'hpfenc1:' ) . '%', self::ENCRYPT_BATCH_SIZE ), ARRAY_A ); if ( ! $rows ) { break; } foreach ( $rows as $row ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table, writes the sealed row $wpdb->update( $table, array( 'data' => HP_Forms_Crypt::encrypt( (string) $row['data'] ), 'context' => HP_Forms_Crypt::is_sealed( $row['context'] ) ? $row['context'] : HP_Forms_Crypt::encrypt( (string) $row['context'] ), ), array( 'id' => (int) $row['id'] ), array( '%s', '%s' ), array( '%d' ) ); ++$encrypted; } } delete_transient( 'holmesforms_enc_has_sealed' ); wp_safe_redirect( add_query_arg( array( 'page' => self::SETTINGS_SLUG, 'tab' => 'data', 'holmesforms_enc' => $encrypted, ), admin_url( 'admin.php' ) ) ); exit; } public function field_ref_prefix() { printf( ' %s', (int) self::MAX_REF_PREFIX_LENGTH, esc_attr( self::SETTINGS ), esc_attr( $this->get_setting( 'ref_prefix', 'ENQ' ) ), esc_html__( 'Every submission gets a reference like ENQ-000042, shown in the entry, the spreadsheet export and any email or submit message that uses the [reference] token. The number only ever counts up, so a reference is never reused even after an entry is deleted. Letters and digits only.', 'holmesportfolio-forms' ) ); } public function field_spam_provider() { $value = $this->get_setting( 'spam_provider', 'none' ); $options = array( 'none' => __( 'None', 'holmesportfolio-forms' ), 'turnstile' => __( 'Cloudflare Turnstile (GOV.UK does not recommend it)', 'holmesportfolio-forms' ), 'recaptcha' => __( 'Google reCAPTCHA v2, the “I’m not a robot” box (GOV.UK does not recommend it)', 'holmesportfolio-forms' ), ); self::render_setting_select( 'hpf-spam-provider', 'spam_provider', $options, $value ); } public function field_visitor_ip() { $value = $this->get_setting( 'visitor_ip', 'direct' ); $options = array( 'direct' => __( 'Straight from the visitor (most sites)', 'holmesportfolio-forms' ), 'cloudflare' => __( 'Through Cloudflare', 'holmesportfolio-forms' ), 'proxy' => __( 'Through another proxy or load balancer my host runs', 'holmesportfolio-forms' ), ); self::render_setting_select( 'hpf-visitor-ip', 'visitor_ip', $options, $value ); echo '

    ' . esc_html__( 'How the site finds a visitor\'s address for the spam limits. Behind Cloudflare or a proxy, "Straight from the visitor" makes every visitor look like one address. Busy forms can then turn real people away with "Too many attempts". Only pick a proxy if one really is in front of your site. Otherwise a visitor could fake their address. The limit is 30 sends in 10 minutes from one address. People sharing one office or school connection count as one address. Signed-in staff are not counted. On the proxy choice, the forwarded address is only believed from a private address, or one listed with the holmesforms_trusted_proxies filter.', 'holmesportfolio-forms' ) . '

    '; if ( 'direct' === $value && get_transient( 'holmesforms_proxy_seen' ) ) { echo '

    ' . esc_html__( 'Recent visits arrived with proxy or Cloudflare headers. Your site probably sits behind one, so check this setting.', 'holmesportfolio-forms' ) . '

    '; } } public function field_flood_limit() { printf( ' %s

    %s

    ', esc_attr( self::SETTINGS ), (int) $this->get_setting( 'flood_limit', HOLMESFORMS_DEFAULT_FLOOD_LIMIT ), esc_html__( 'entries to one form in an hour (0 = off)', 'holmesportfolio-forms' ), esc_html__( 'Above this, the site emails the site admin address once a day for that form and shows a notice here, because it may be spam. Entries are still accepted.', 'holmesportfolio-forms' ) ); } public function field_captcha_keys() { $provider = $this->get_setting( 'spam_provider', 'none' ); $keys_by_provider = array( 'turnstile' => array( 'turnstile_site' => __( 'Turnstile site key', 'holmesportfolio-forms' ), 'turnstile_secret' => __( 'Turnstile secret key', 'holmesportfolio-forms' ), ), 'recaptcha' => array( 'recaptcha_site' => __( 'reCAPTCHA site key', 'holmesportfolio-forms' ), 'recaptcha_secret' => __( 'reCAPTCHA secret key', 'holmesportfolio-forms' ), ), ); $cookie_warnings = array( 'turnstile' => __( 'With Turnstile on, Cloudflare\'s check loads with your forms and reads information from your visitors\' browsers and devices to tell people from bots. Cloudflare can also set its own cookies while it runs. HolmesPortfolio Forms sets no cookies of its own. Add Cloudflare to your cookie notice and privacy policy. The UK\'s cookie rules (the Privacy and Electronic Communications Regulations) cover reading information from a device, not only cookies. Check whether you need visitors\' consent first.', 'holmesportfolio-forms' ), 'recaptcha' => __( 'With reCAPTCHA on, Google\'s check loads with your forms, sets a cookie called _GRECAPTCHA and reads information from your visitors\' browsers and devices. Google may also receive other Google cookies a visitor already has. HolmesPortfolio Forms sets no cookies of its own. Add Google to your cookie notice and privacy policy. The UK\'s cookie rules (the Privacy and Electronic Communications Regulations) cover reading information from a device, not only cookies. Check whether you need visitors\' consent first.', 'holmesportfolio-forms' ), ); foreach ( $keys_by_provider as $group_provider => $fields ) { $hidden_attribute = $provider === $group_provider ? '' : ' hidden'; echo wp_kses( '
    ', HP_Forms_Kses::admin() ); echo ''; foreach ( $fields as $key => $label ) { $is_secret = false !== strpos( $key, '_secret' ); $is_saved = $is_secret && '' !== (string) $this->get_setting( $key ); printf( '

    ', esc_html( $label ), $is_secret ? 'password' : 'text', esc_attr( self::SETTINGS ), esc_attr( $key ), $is_secret ? '' : esc_attr( $this->get_setting( $key ) ), $is_saved ? ' aria-describedby="hpf-' . esc_attr( $key ) . '-saved"' : '' ); if ( $is_saved ) { echo '

    ' . esc_html__( 'Already saved. Leave the box blank to keep it. To remove both keys, empty the site key box above and save.', 'holmesportfolio-forms' ) . '

    '; } } echo '
    '; } echo '

    ' . esc_html__( 'Keys for the provider you are not using are kept, so you can switch back. To remove a provider\'s keys, clear its site key and save. That removes its secret key too.', 'holmesportfolio-forms' ) . '

    '; if ( 'recaptcha' === $provider ) { $this->render_captcha_key_note( __( 'Which reCAPTCHA keys work', 'holmesportfolio-forms' ), array( __( 'reCAPTCHA v2, "I\'m not a robot" checkbox', 'holmesportfolio-forms' ) ), array( __( 'reCAPTCHA v3', 'holmesportfolio-forms' ), __( 'reCAPTCHA v2 Invisible', 'holmesportfolio-forms' ), __( 'reCAPTCHA v2 Android', 'holmesportfolio-forms' ) ), __( 'Pick Turnstile if you want a check that shows nothing to most visitors.', 'holmesportfolio-forms' ) ); } elseif ( 'turnstile' === $provider ) { $this->render_captcha_key_note( __( 'Which Turnstile keys work', 'holmesportfolio-forms' ), array( __( 'Managed', 'holmesportfolio-forms' ), __( 'Non-Interactive', 'holmesportfolio-forms' ), __( 'Invisible', 'holmesportfolio-forms' ) ), array(), __( 'Non-Interactive and Invisible show no challenge to most visitors.', 'holmesportfolio-forms' ) ); } if ( 'none' !== $provider ) { echo '

    '; echo '

    ' . esc_html__( 'Checks the saved secret key with the provider. The site key is checked when the check first loads on a form. Save your changes first.', 'holmesportfolio-forms' ) . '

    '; } } /* Save / delete */ public function handle_save_form() { if ( ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_save_form', 'holmesforms_nonce' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; $is_new = ! $form_id; self::die_unless_editable_form( $form_id ); if ( $form_id ) { self::die_if_open_elsewhere( $form_id ); } $title = isset( $_POST['form_title'] ) ? sanitize_text_field( wp_unslash( $_POST['form_title'] ) ) : __( 'Untitled form', 'holmesportfolio-forms' ); $definition_json = isset( $_POST['holmesforms_definition'] ) ? sanitize_textarea_field( wp_unslash( $_POST['holmesforms_definition'] ) ) : ''; $renames_json = isset( $_POST['holmesforms_renames'] ) ? sanitize_textarea_field( wp_unslash( $_POST['holmesforms_renames'] ) ) : ''; $definition = json_decode( $definition_json, true ); if ( ! is_array( $definition ) ) { $definition = array(); } $form_id = self::save_form_post( $form_id, $title ); $group = isset( $_POST['form_group'] ) ? sanitize_text_field( wp_unslash( $_POST['form_group'] ) ) : ''; $group = trim( preg_replace( '/\s+/', ' ', $group ) ); wp_set_object_terms( $form_id, '' !== $group ? array( $group ) : array(), HOLMESFORMS_FORM_GROUP_TAX, false ); $note = isset( $_POST['holmesforms_save_note'] ) ? sanitize_text_field( wp_unslash( $_POST['holmesforms_save_note'] ) ) : ''; $old_keys = $is_new ? array() : self::field_keys_of( $form_id ); $this->save_definition( $form_id, $definition ); $this->snapshot_revision( $form_id, $this->export_definition( $form_id ), $note ); $server_renames = $this->last_server_renames; if ( $server_renames ) { set_transient( 'holmesforms_renamed_' . get_current_user_id() . '_' . $form_id, $server_renames, 5 * MINUTE_IN_SECONDS ); } if ( ! $is_new ) { foreach ( $server_renames as $from => $to ) { do_action( 'holmesforms_key_renamed', $form_id, 'field', $from, $to ); } foreach ( self::announce_key_renames( $form_id, $renames_json ) + $server_renames as $from => $to ) { $old_keys = array_map( static function ( $key ) use ( $from, $to ) { return $key === $from ? $to : $key; }, $old_keys ); } $removed_keys = array_values( array_diff( $old_keys, self::field_keys_of( $form_id ) ) ); if ( $removed_keys ) { /** * @param int $form_id * @param string[] $removed_keys Data Names no longer on the form. */ do_action( 'holmesforms_keys_removed', $form_id, $removed_keys ); } } if ( $is_new ) { do_action( 'holmesforms_form_created', $form_id, get_current_user_id() ); } $after_save = isset( $_POST['holmesforms_after_save'] ) ? sanitize_key( wp_unslash( $_POST['holmesforms_after_save'] ) ) : 'continue'; if ( 'close' === $after_save ) { wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'holmesforms_notice' => 'saved', 'holmesforms_saved_form' => $form_id, ), admin_url( 'admin.php' ) ) ); exit; } wp_safe_redirect( add_query_arg( array( 'page' => self::BUILD_SLUG, 'form' => $form_id, 'holmesforms_notice' => 'saved', ), admin_url( 'admin.php' ) ) ); exit; } private static function die_if_open_elsewhere( $form_id ) { $lock_holder = self::other_editor_id( $form_id ); if ( ! $lock_holder ) { return; } wp_die( esc_html( sprintf( /* translators: %s: display name */ __( 'This form is now being edited by %s. Your changes were not saved. Open the form again to pick up their version.', 'holmesportfolio-forms' ), self::user_display_name( $lock_holder, __( 'someone else', 'holmesportfolio-forms' ) ) ) ), '', array( 'response' => 409, 'link_url' => esc_url( admin_url( 'admin.php?page=' . self::BUILD_SLUG . '&form=' . (int) $form_id ) ), 'link_text' => esc_html__( 'Open the form again', 'holmesportfolio-forms' ), ) ); } /** @return int Saved form id; a failed update is saved as a new form. */ private static function save_form_post( $form_id, $title ) { $postarr = array( 'post_type' => HOLMESFORMS_POST_TYPE, 'post_status' => 'publish', 'post_title' => $title, ); if ( $form_id ) { $postarr['ID'] = $form_id; $form_id = wp_update_post( $postarr, true ); if ( is_wp_error( $form_id ) ) { unset( $postarr['ID'] ); $form_id = wp_insert_post( $postarr, true ); } } else { $form_id = wp_insert_post( $postarr, true ); } if ( is_wp_error( $form_id ) ) { wp_die( esc_html( $form_id->get_error_message() ) ); } return (int) $form_id; } /** @return string[] Every Data Name on the saved form, groups included. */ private static function field_keys_of( $form_id ) { $keys = array(); $walk = static function ( array $fields ) use ( &$walk, &$keys ) { foreach ( $fields as $field ) { if ( is_array( $field ) && ! empty( $field['key'] ) ) { $keys[] = (string) $field['key']; } if ( is_array( $field ) && ! empty( $field['config']['children'] ) && is_array( $field['config']['children'] ) ) { $walk( $field['config']['children'] ); } } }; foreach ( (array) get_post_meta( (int) $form_id, HP_Forms_Form::META_PAGES, true ) as $page ) { if ( is_array( $page ) && ! empty( $page['fields'] ) && is_array( $page['fields'] ) ) { $walk( $page['fields'] ); } } return array_values( array_unique( $keys ) ); } /** @return array Question renames this save, old Data Name => new, in the order made. */ /** @param string $renames_json The builder's rename log, already cleaned as text. */ private static function announce_key_renames( $form_id, $renames_json ) { $field_renames = array(); $renames = '' !== $renames_json ? json_decode( $renames_json, true ) : array(); $renames = is_array( $renames ) ? map_deep( $renames, 'sanitize_text_field' ) : array(); foreach ( array_slice( is_array( $renames ) ? $renames : array(), 0, self::MAX_RENAMES_PER_SAVE ) as $rename ) { if ( ! is_array( $rename ) ) { continue; } $from = isset( $rename['from'] ) ? sanitize_key( (string) $rename['from'] ) : ''; $to = isset( $rename['to'] ) ? sanitize_key( (string) $rename['to'] ) : ''; if ( '' !== $from && '' !== $to && $from !== $to ) { $kind = isset( $rename['kind'] ) && 'var' === $rename['kind'] ? 'var' : 'field'; do_action( 'holmesforms_key_renamed', $form_id, $kind, $from, $to ); if ( 'field' === $kind ) { $field_renames[ $from ] = $to; } } } return $field_renames; } private static function collect_locked_field_ids( array $pages ) { $ids = array(); $collect = function ( array $fields ) use ( &$ids, &$collect ) { foreach ( $fields as $field ) { if ( ! is_array( $field ) ) { continue; } if ( ! HP_Forms_Components::is_addable( isset( $field['type'] ) ? $field['type'] : '' ) ) { $ids[ (string) ( $field['id'] ?? '' ) ] = true; } if ( 'container' === ( $field['type'] ?? '' ) && ! empty( $field['config']['children'] ) ) { $collect( (array) $field['config']['children'] ); } } }; foreach ( $pages as $page ) { if ( is_array( $page ) && ! empty( $page['fields'] ) ) { $collect( (array) $page['fields'] ); } } return $ids; } /** @param array $allowed_ids Ids of fields that can't be added but were already on the form before this save. */ private static function strip_new_locked_fields( array $fields, array $allowed_ids ) { $kept = array(); foreach ( $fields as $field ) { if ( ! HP_Forms_Components::is_addable( isset( $field['type'] ) ? $field['type'] : '' ) && ! isset( $allowed_ids[ (string) ( $field['id'] ?? '' ) ] ) ) { continue; } if ( 'container' === ( $field['type'] ?? '' ) && ! empty( $field['config']['children'] ) ) { $field['config']['children'] = self::strip_new_locked_fields( (array) $field['config']['children'], $allowed_ids ); } $kept[] = $field; } return $kept; } /** @param mixed $raw_variables Variables from the builder or an import file. */ private static function sanitize_variables_def( $raw_variables ) { $variables = array(); foreach ( (array) $raw_variables as $var ) { if ( ! is_array( $var ) || empty( $var['key'] ) ) { continue; } $rules = array(); foreach ( (array) ( isset( $var['rules'] ) ? $var['rules'] : array() ) as $rule ) { if ( ! is_array( $rule ) ) { continue; } $rules[] = array( 'when' => self::sanitize_ruleset_def( isset( $rule['when'] ) ? $rule['when'] : null ), 'op' => isset( $rule['op'] ) ? sanitize_key( $rule['op'] ) : 'set', 'value' => isset( $rule['value'] ) ? sanitize_textarea_field( $rule['value'] ) : '', ); } $variables[] = array( 'key' => sanitize_key( $var['key'] ), 'name' => isset( $var['name'] ) ? sanitize_text_field( $var['name'] ) : '', 'note' => isset( $var['note'] ) ? sanitize_text_field( $var['note'] ) : '', 'default' => isset( $var['default'] ) ? sanitize_text_field( $var['default'] ) : '', 'formula' => isset( $var['formula'] ) ? sanitize_text_field( $var['formula'] ) : '', 'round' => isset( $var['round'] ) && in_array( (string) $var['round'], HP_Forms_Logic::ROUNDING_OPTIONS, true ) ? (string) $var['round'] : '', 'before' => isset( $var['before'] ) ? mb_substr( sanitize_text_field( $var['before'] ), 0, self::MAX_AFFIX_LENGTH ) : '', 'after' => isset( $var['after'] ) ? mb_substr( sanitize_text_field( $var['after'] ), 0, self::MAX_AFFIX_LENGTH ) : '', 'rules' => $rules, ); } return $variables; } /** @return string[] Up to two field keys that make the entry title. */ private static function sanitize_entry_label( $keys ) { $labels = array(); foreach ( array_slice( (array) $keys, 0, self::MAX_ENTRY_LABEL_KEYS ) as $key ) { $key = sanitize_key( is_scalar( $key ) ? (string) $key : '' ); if ( '' !== $key ) { $labels[] = $key; } } return array_values( array_unique( $labels ) ); } /** @return array Field id => Data Name, containers included. */ private static function keys_by_field_id( array $pages ) { $keys = array(); $walk = static function ( array $fields ) use ( &$walk, &$keys ) { foreach ( $fields as $field ) { if ( is_array( $field ) && isset( $field['id'], $field['key'] ) && '' !== (string) $field['key'] ) { $keys[ (string) $field['id'] ] = (string) $field['key']; } if ( is_array( $field ) && ! empty( $field['config']['children'] ) && is_array( $field['config']['children'] ) ) { $walk( $field['config']['children'] ); } } }; foreach ( $pages as $page ) { if ( is_array( $page ) && ! empty( $page['fields'] ) && is_array( $page['fields'] ) ) { $walk( $page['fields'] ); } } return $keys; } private function save_definition( $form_id, array $definition ) { $existing_locked_ids = self::collect_locked_field_ids( (array) get_post_meta( $form_id, HP_Forms_Form::META_PAGES, true ) ); $meta_values = array(); $pages = self::sanitize_pages_def( isset( $definition['pages'] ) ? $definition['pages'] : array() ); foreach ( $pages as &$page_ref ) { $page_ref['fields'] = self::strip_new_locked_fields( $page_ref['fields'], $existing_locked_ids ); } unset( $page_ref ); $keys_before = self::keys_by_field_id( $pages ); list( $pages, $variables ) = HP_Forms_Form::with_unique_keys( $pages, self::sanitize_variables_def( isset( $definition['variables'] ) ? $definition['variables'] : array() ) ); $this->last_server_renames = array(); foreach ( self::keys_by_field_id( $pages ) as $field_id => $new_key ) { if ( isset( $keys_before[ $field_id ] ) && $keys_before[ $field_id ] !== $new_key ) { $this->last_server_renames[ $keys_before[ $field_id ] ] = $new_key; } } $meta_values[ HP_Forms_Form::META_PAGES ] = $pages; $meta_values[ HP_Forms_Form::META_VARIABLES ] = $variables; self::update_or_delete_meta( $form_id, HP_Forms_Form::META_DEBUG, 1, ! empty( $definition['debug'] ) ); $inbox = isset( $definition['group_inbox'] ) ? sanitize_email( wp_unslash( $definition['group_inbox'] ) ) : ''; self::update_or_delete_meta( $form_id, HP_Forms_Form::META_GROUP_INBOX, $inbox, $inbox && is_email( $inbox ) ); $label_keys = isset( $definition['entry_label'] ) && is_array( $definition['entry_label'] ) ? self::sanitize_entry_label( wp_unslash( $definition['entry_label'] ) ) : array(); self::update_or_delete_meta( $form_id, HP_Forms_Form::META_ENTRY_LABEL, $label_keys, (bool) $label_keys ); $ref_prefix = isset( $definition['ref_prefix'] ) ? substr( strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', (string) wp_unslash( $definition['ref_prefix'] ) ) ), 0, self::MAX_REF_PREFIX_LENGTH ) : ''; self::update_or_delete_meta( $form_id, HP_Forms_Form::META_REF_PREFIX, $ref_prefix, '' !== $ref_prefix ); $bh_region = HP_Forms_Form::clean_bank_holiday_region( isset( $definition['bh_region'] ) ? wp_unslash( $definition['bh_region'] ) : '' ); self::update_or_delete_meta( $form_id, HP_Forms_Form::META_BH_REGION, $bh_region, '' !== $bh_region ); $case_view = isset( $definition['case_view'] ) ? HP_Forms_Components::kses_keeping_if_blocks( (string) wp_unslash( $definition['case_view'] ) ) : ''; $has_case_view = '' !== trim( $case_view ); self::update_or_delete_meta( $form_id, HP_Forms_Form::META_CASE_VIEW, $case_view, $has_case_view ); /** Persists an add-on's builder Setup-tab settings. */ do_action( 'holmesforms_save_form_ext', $form_id, ( isset( $definition['ext'] ) && is_array( $definition['ext'] ) ) ? wp_unslash( $definition['ext'] ) : array() ); foreach ( $meta_values as $key => $value ) { update_post_meta( $form_id, $key, wp_slash( $value ) ); } try { update_post_meta( $form_id, '_holmesforms_a11y', HP_Forms_A11y::count_by_severity( HP_Forms_A11y::review( array( 'pages' => $pages ) ) ) ); } catch ( \Throwable $e ) { delete_post_meta( $form_id, '_holmesforms_a11y' ); } /** @param int $form_id The form just saved, with its definition and settings in place. */ do_action( 'holmesforms_form_saved', (int) $form_id ); } private static function update_or_delete_meta( $form_id, $meta_key, $value, $should_keep ) { if ( $should_keep ) { update_post_meta( $form_id, $meta_key, wp_slash( $value ) ); } else { delete_post_meta( $form_id, $meta_key ); } } /** @return array[] Sanitized pages, each with a cleaned 'fields' array. */ public static function sanitize_pages_def( $pages_raw ) { $pages = array(); foreach ( (array) $pages_raw as $page ) { if ( ! is_array( $page ) ) { continue; } $fields = array(); foreach ( (array) ( isset( $page['fields'] ) ? $page['fields'] : array() ) as $field ) { $clean = self::sanitize_field_def( $field ); if ( $clean ) { $fields[] = $clean; } } $pages[] = array( 'id' => isset( $page['id'] ) ? sanitize_key( $page['id'] ) : '', 'title' => isset( $page['title'] ) ? sanitize_text_field( $page['title'] ) : '', 'visible_when' => self::sanitize_ruleset_def( isset( $page['visible_when'] ) ? $page['visible_when'] : null ), 'visible_mode' => ( isset( $page['visible_mode'] ) && 'hide' === $page['visible_mode'] ) ? 'hide' : 'show', 'fields' => $fields, ); } return $pages; } /** @return array|null Null drops the field. */ private static function sanitize_field_def( $field ) { if ( ! is_array( $field ) || empty( $field['type'] ) ) { return null; } $type = sanitize_key( $field['type'] ); $options = self::sanitize_option_defs( isset( $field['options'] ) ? $field['options'] : array() ); $config = isset( $field['config'] ) && is_array( $field['config'] ) ? $field['config'] : array(); if ( 'container' === $type ) { $config['children'] = self::sanitize_child_field_defs( isset( $config['children'] ) ? $config['children'] : array() ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-components.php'; $config = HP_Forms_Components::sanitize_config( $type, $config ); return array( 'id' => isset( $field['id'] ) ? sanitize_key( $field['id'] ) : '', 'type' => $type, 'key' => isset( $field['key'] ) ? sanitize_key( $field['key'] ) : '', 'label' => isset( $field['label'] ) ? sanitize_text_field( $field['label'] ) : '', 'hint' => isset( $field['hint'] ) ? HP_Forms_Components::hint_html( $field['hint'] ) : '', 'required' => ! empty( $field['required'] ), 'errors' => isset( $field['errors'] ) && is_array( $field['errors'] ) ? array_map( 'sanitize_text_field', $field['errors'] ) : array(), 'options' => $options, 'config' => $config, 'described_by' => isset( $field['described_by'] ) ? sanitize_key( $field['described_by'] ) : '', 'visible_when' => self::sanitize_ruleset_def( isset( $field['visible_when'] ) ? $field['visible_when'] : null ), 'visible_mode' => ( isset( $field['visible_mode'] ) && 'hide' === $field['visible_mode'] ) ? 'hide' : 'show', 'clear_on_hide' => ! empty( $field['clear_on_hide'] ), 'clear_on_change' => ! empty( $field['clear_on_change'] ), ); } private static function sanitize_option_defs( $options ) { $clean_options = array(); foreach ( (array) $options as $option ) { if ( ! is_array( $option ) ) { $clean_options[] = array( 'label' => sanitize_text_field( (string) $option ), 'value' => sanitize_text_field( (string) $option ), ); continue; } $clean_option = array( 'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '', 'value' => isset( $option['value'] ) ? sanitize_text_field( $option['value'] ) : '', ); if ( ! empty( $option['described_by'] ) ) { $clean_option['described_by'] = sanitize_key( $option['described_by'] ); } if ( ! empty( $option['match'] ) ) { $clean_option['match'] = sanitize_text_field( $option['match'] ); } if ( ! empty( $option['group'] ) ) { $clean_option['group'] = sanitize_text_field( $option['group'] ); } $clean_options[] = $clean_option; } return $clean_options; } private static function sanitize_child_field_defs( $children ) { $clean_children = array(); foreach ( (array) $children as $child ) { $clean_child = self::sanitize_field_def( $child ); if ( $clean_child ) { $clean_children[] = $clean_child; } } return $clean_children; } private static function sanitize_ruleset_def( $ruleset ) { if ( empty( $ruleset ) || ! is_array( $ruleset ) ) { return null; } $rows = isset( $ruleset['rows'] ) && is_array( $ruleset['rows'] ) ? $ruleset['rows'] : $ruleset; $clean = array(); foreach ( (array) $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $subject = isset( $row['subject'] ) ? sanitize_text_field( $row['subject'] ) : ''; if ( '' === $subject ) { continue; } $clean[] = array( 'subject' => $subject, 'op' => isset( $row['op'] ) ? sanitize_key( $row['op'] ) : 'eq', 'value' => isset( $row['value'] ) ? sanitize_text_field( $row['value'] ) : '', 'join' => ( isset( $row['join'] ) && 'or' === $row['join'] ) ? 'or' : 'and', ); } return $clean ? array( 'rows' => $clean ) : null; } private function snapshot_revision( $form_id, array $definition, $note ) { $revisions = get_post_meta( $form_id, self::REVISION_META, true ); if ( ! is_array( $revisions ) ) { $revisions = array(); } array_unshift( $revisions, array( 't' => gmdate( 'Y-m-d H:i:s' ), 'u' => get_current_user_id(), 'note' => mb_substr( (string) $note, 0, self::MAX_REVISION_NOTE_LENGTH ), 'def' => $definition, ) ); update_post_meta( $form_id, self::REVISION_META, wp_slash( array_slice( $revisions, 0, self::REVISION_MAX ) ) ); } private function get_revisions( $form_id ) { $revisions = get_post_meta( $form_id, self::REVISION_META, true ); return is_array( $revisions ) ? $revisions : array(); } public function ajax_revisions() { check_ajax_referer( 'holmesforms_revisions', 'nonce' ); $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) ) { wp_send_json_error(); } $history = array(); foreach ( $this->get_revisions( $form_id ) as $i => $revision ) { $user = $revision['u'] ? get_userdata( (int) $revision['u'] ) : null; $history[] = array( 'i' => $i, 'when' => holmesforms_date( mysql2date( 'U', $revision['t'], false ), true ), 'who' => $user ? $user->display_name : __( 'Someone', 'holmesportfolio-forms' ), 'note' => (string) $revision['note'], ); } wp_send_json_success( $history ); } public function ajax_revision_diff() { check_ajax_referer( 'holmesforms_revisions', 'nonce' ); $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; $left_index = isset( $_POST['a'] ) ? absint( $_POST['a'] ) : 0; $right_index = isset( $_POST['b'] ) ? absint( $_POST['b'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) ) { wp_send_json_error(); } $revisions = $this->get_revisions( $form_id ); if ( ! isset( $revisions[ $left_index ], $revisions[ $right_index ] ) ) { wp_send_json_error(); } require_once ABSPATH . 'wp-admin/includes/revision.php'; $changes = self::version_changes( (array) $revisions[ $left_index ]['def'], (array) $revisions[ $right_index ]['def'] ); $left = wp_json_encode( $revisions[ $left_index ]['def'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); $right = wp_json_encode( $revisions[ $right_index ]['def'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); $diff = wp_text_diff( $left, $right, array( 'title_left' => __( 'Older', 'holmesportfolio-forms' ), 'title_right' => __( 'Newer', 'holmesportfolio-forms' ), ) ); if ( ! $diff ) { wp_send_json_success( array( 'diff' => '

    ' . esc_html__( 'These two versions are identical.', 'holmesportfolio-forms' ) . '

    ' ) ); } $html = '

    ' . esc_html__( 'What changed, from the older version to the newer one', 'holmesportfolio-forms' ) . '

    '; $html .= $changes ? '
    • ' . implode( '
    • ', array_map( 'esc_html', $changes ) ) . '
    ' : '

    ' . esc_html__( 'Only small behind-the-scenes details changed.', 'holmesportfolio-forms' ) . '

    '; $html .= '
    ' . esc_html__( 'Show the full technical comparison', 'holmesportfolio-forms' ) . '' . $diff . '
    '; wp_send_json_success( array( 'diff' => $html ) ); } /** @return array Field id => [ field, [ page id, page title ] ], groups included. */ private static function version_fields_by_id( array $definition ) { $fields_by_id = array(); $collect = static function ( array $fields, $page ) use ( &$collect, &$fields_by_id ) { foreach ( $fields as $field ) { if ( ! is_array( $field ) || empty( $field['id'] ) ) { continue; } $fields_by_id[ (string) $field['id'] ] = array( $field, $page ); if ( ! empty( $field['config']['children'] ) && is_array( $field['config']['children'] ) ) { $collect( $field['config']['children'], $page ); } } }; foreach ( (array) ( $definition['pages'] ?? array() ) as $i => $page_data ) { $collect( (array) ( $page_data['fields'] ?? array() ), array( (string) ( $page_data['id'] ?? $i ), self::version_page_title( $page_data, $i ) ) ); } return $fields_by_id; } private static function version_page_title( $page_data, $index ) { if ( '' !== trim( (string) ( $page_data['title'] ?? '' ) ) ) { return (string) $page_data['title']; } /* translators: %d: page number */ return sprintf( __( 'Page %d', 'holmesportfolio-forms' ), $index + 1 ); } /** @return string[] Page id => title. */ private static function version_page_titles( array $definition ) { $titles = array(); foreach ( (array) ( $definition['pages'] ?? array() ) as $i => $page_data ) { $titles[ (string) ( $page_data['id'] ?? $i ) ] = self::version_page_title( $page_data, $i ); } return $titles; } private static function version_field_name( array $field ) { $types = HP_Forms_Components::types(); $label = trim( wp_strip_all_tags( (string) ( $field['label'] ?? '' ) ) ); if ( '' === $label ) { $label = isset( $types[ $field['type'] ?? '' ]['label'] ) ? $types[ $field['type'] ]['label'] : (string) ( $field['key'] ?? $field['type'] ?? '' ); } return $label; } private static function quote_name( $text ) { return '“' . $text . '”'; } /** @return string[] What changed from $old_definition to $new_definition, in plain words. */ public static function version_changes( array $old_definition, array $new_definition ) { $old_fields = self::version_fields_by_id( $old_definition ); $new_fields = self::version_fields_by_id( $new_definition ); return array_merge( self::describe_page_changes( $old_definition, $new_definition ), self::describe_question_changes( $old_fields, $new_fields ), self::describe_question_order_changes( $old_fields, $new_fields, self::version_page_titles( $new_definition ) ), self::describe_calculation_changes( $old_definition, $new_definition ), self::describe_setup_changes( $old_definition, $new_definition ) ); } private static function describe_page_changes( array $old_definition, array $new_definition ) { $changes = array(); $old_pages = self::version_page_titles( $old_definition ); $new_pages = self::version_page_titles( $new_definition ); foreach ( $new_pages as $id => $name ) { if ( ! isset( $old_pages[ $id ] ) ) { /* translators: %s: page title */ $changes[] = sprintf( __( 'Added the page %s.', 'holmesportfolio-forms' ), self::quote_name( $name ) ); } elseif ( $old_pages[ $id ] !== $name ) { /* translators: 1: old page title, 2: new page title */ $changes[] = sprintf( __( 'Renamed the page %1$s to %2$s.', 'holmesportfolio-forms' ), self::quote_name( $old_pages[ $id ] ), self::quote_name( $name ) ); } } foreach ( $old_pages as $id => $name ) { if ( ! isset( $new_pages[ $id ] ) ) { /* translators: %s: page title */ $changes[] = sprintf( __( 'Deleted the page %s.', 'holmesportfolio-forms' ), self::quote_name( $name ) ); } } if ( array_values( array_intersect( array_keys( $old_pages ), array_keys( $new_pages ) ) ) !== array_values( array_intersect( array_keys( $new_pages ), array_keys( $old_pages ) ) ) ) { $changes[] = __( 'Changed the order of the pages.', 'holmesportfolio-forms' ); } foreach ( $new_pages as $id => $name ) { $old_page_data = $old_definition['pages'][ array_search( $id, array_keys( $old_pages ), true ) ] ?? null; $new_page_data = $new_definition['pages'][ array_search( $id, array_keys( $new_pages ), true ) ] ?? null; if ( is_array( $old_page_data ) && is_array( $new_page_data ) && wp_json_encode( $old_page_data['visible_when'] ?? null ) !== wp_json_encode( $new_page_data['visible_when'] ?? null ) ) { /* translators: %s: page title */ $changes[] = sprintf( __( 'Changed when the page %s shows.', 'holmesportfolio-forms' ), self::quote_name( $name ) ); } } return $changes; } private static function describe_question_changes( array $old_fields, array $new_fields ) { $changes = array(); foreach ( $new_fields as $id => $pair ) { list( $new_field, $page ) = $pair; $name = self::quote_name( self::version_field_name( $new_field ) ); if ( ! isset( $old_fields[ $id ] ) ) { /* translators: 1: question name, 2: page title */ $changes[] = sprintf( __( 'Added %1$s on the page %2$s.', 'holmesportfolio-forms' ), $name, self::quote_name( $page[1] ) ); continue; } list( $old_field, $old_page ) = $old_fields[ $id ]; if ( $old_page[0] !== $page[0] ) { /* translators: 1: question name, 2: old page, 3: new page */ $changes[] = sprintf( __( 'Moved %1$s from %2$s to %3$s.', 'holmesportfolio-forms' ), $name, self::quote_name( $old_page[1] ), self::quote_name( $page[1] ) ); } $changes = array_merge( $changes, self::describe_question_edits( $old_field, $new_field, $name ) ); } foreach ( $old_fields as $id => $pair ) { if ( ! isset( $new_fields[ $id ] ) ) { /* translators: 1: question name, 2: page title */ $changes[] = sprintf( __( 'Removed %1$s from the page %2$s.', 'holmesportfolio-forms' ), self::quote_name( self::version_field_name( $pair[0] ) ), self::quote_name( $pair[1][1] ) ); } } return $changes; } private static function describe_question_edits( array $old_field, array $new_field, $name ) { $changes = array(); if ( (string) ( $old_field['label'] ?? '' ) !== (string) ( $new_field['label'] ?? '' ) ) { /* translators: 1: old wording, 2: new wording */ $changes[] = sprintf( __( 'Changed the wording %1$s to %2$s.', 'holmesportfolio-forms' ), self::quote_name( self::version_field_name( $old_field ) ), $name ); } if ( ! empty( $old_field['required'] ) !== ! empty( $new_field['required'] ) ) { /* translators: %s: question name */ $changes[] = sprintf( ! empty( $new_field['required'] ) ? __( 'Made %s required.', 'holmesportfolio-forms' ) : __( 'Made %s optional.', 'holmesportfolio-forms' ), $name ); } $parts = array( 'hint' => __( 'hint', 'holmesportfolio-forms' ), 'options' => __( 'choices', 'holmesportfolio-forms' ), 'errors' => __( 'error messages', 'holmesportfolio-forms' ), 'visible_when' => __( 'show/hide rule', 'holmesportfolio-forms' ), 'key' => __( 'Data Name', 'holmesportfolio-forms' ), ); $changed_parts = array(); foreach ( $parts as $key => $words ) { if ( wp_json_encode( $old_field[ $key ] ?? null ) !== wp_json_encode( $new_field[ $key ] ?? null ) ) { $changed_parts[] = $words; } } $old_config = (array) ( $old_field['config'] ?? array() ); $new_config = (array) ( $new_field['config'] ?? array() ); unset( $old_config['children'], $new_config['children'] ); if ( wp_json_encode( $old_config ) !== wp_json_encode( $new_config ) ) { $changed_parts[] = __( 'settings', 'holmesportfolio-forms' ); } if ( $changed_parts ) { /* translators: 1: question name, 2: list of what changed, like "hint, settings" */ $changes[] = sprintf( __( 'Changed the %2$s of %1$s.', 'holmesportfolio-forms' ), $name, wp_sprintf( '%l', $changed_parts ) ); } return $changes; } private static function describe_question_order_changes( array $old_fields, array $new_fields, array $new_pages ) { $changes = array(); $old_order = self::question_order_by_page( $old_fields, $new_fields ); foreach ( self::question_order_by_page( $new_fields, $old_fields ) as $page_id => $ids ) { if ( isset( $old_order[ $page_id ] ) && $old_order[ $page_id ] !== $ids ) { /* translators: %s: page title */ $changes[] = sprintf( __( 'Changed the order of the questions on %s.', 'holmesportfolio-forms' ), self::quote_name( $new_pages[ $page_id ] ?? '' ) ); } } return $changes; } /** @return array[] Page id => ids of the questions kept on that page. */ private static function question_order_by_page( array $fields, array $other_fields ) { $ids_by_page = array(); foreach ( $fields as $id => $pair ) { if ( isset( $other_fields[ $id ] ) && $other_fields[ $id ][1][0] === $pair[1][0] ) { $ids_by_page[ $pair[1][0] ][] = $id; } } return $ids_by_page; } private static function describe_calculation_changes( array $old_definition, array $new_definition ) { $changes = array(); $old_variables = self::variables_by_key( $old_definition ); $new_variables = self::variables_by_key( $new_definition ); foreach ( $new_variables as $key => $row ) { $name = self::quote_name( self::calculation_name( $row, $key ) ); if ( ! isset( $old_variables[ $key ] ) ) { /* translators: %s: variable name */ $changes[] = sprintf( __( 'Added the calculation %s.', 'holmesportfolio-forms' ), $name ); } elseif ( wp_json_encode( $old_variables[ $key ] ) !== wp_json_encode( $row ) ) { /* translators: %s: variable name */ $changes[] = sprintf( __( 'Changed the calculation %s.', 'holmesportfolio-forms' ), $name ); } } foreach ( $old_variables as $key => $row ) { if ( ! isset( $new_variables[ $key ] ) ) { /* translators: %s: variable name */ $changes[] = sprintf( __( 'Removed the calculation %s.', 'holmesportfolio-forms' ), self::quote_name( self::calculation_name( $row, $key ) ) ); } } return $changes; } private static function variables_by_key( array $definition ) { $variables = array(); foreach ( (array) ( $definition['variables'] ?? array() ) as $row ) { if ( ! empty( $row['key'] ) ) { $variables[ (string) $row['key'] ] = $row; } } return $variables; } private static function calculation_name( array $row, $key ) { return '' !== trim( (string) ( $row['name'] ?? '' ) ) ? (string) $row['name'] : $key; } private static function describe_setup_changes( array $old_definition, array $new_definition ) { $changes = array(); $setup_changes = array( 'group_inbox' => __( 'Changed the group inbox.', 'holmesportfolio-forms' ), 'ref_prefix' => __( 'Changed the reference prefix.', 'holmesportfolio-forms' ), 'entry_label' => __( 'Changed the entry title.', 'holmesportfolio-forms' ), 'case_view' => __( 'Changed what staff see for an entry.', 'holmesportfolio-forms' ), 'debug' => __( 'Turned the front-end debugger on or off.', 'holmesportfolio-forms' ), 'ext' => __( 'Changed add-on settings on the Setup tab, such as response times.', 'holmesportfolio-forms' ), ); foreach ( $setup_changes as $key => $words ) { if ( wp_json_encode( $old_definition[ $key ] ?? null ) !== wp_json_encode( $new_definition[ $key ] ?? null ) ) { $changes[] = $words; } } return $changes; } public function ajax_delete_revision() { check_ajax_referer( 'holmesforms_revisions', 'nonce' ); $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; $index = isset( $_POST['i'] ) ? absint( $_POST['i'] ) : -1; if ( ! self::is_editable_form( $form_id ) || $index < 0 || ! current_user_can( self::CAP ) ) { wp_send_json_error(); } $revisions = $this->get_revisions( $form_id ); if ( ! isset( $revisions[ $index ] ) ) { wp_send_json_error(); } array_splice( $revisions, $index, 1 ); update_post_meta( $form_id, self::REVISION_META, wp_slash( $revisions ) ); wp_send_json_success(); } public function handle_restore_revision() { $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; $index = isset( $_POST['i'] ) ? absint( $_POST['i'] ) : -1; if ( ! self::is_editable_form( $form_id ) || $index < 0 || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_restore_revision_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } $revisions = $this->get_revisions( $form_id ); if ( ! isset( $revisions[ $index ]['def'] ) || ! is_array( $revisions[ $index ]['def'] ) ) { wp_die( esc_html__( 'That version is no longer available.', 'holmesportfolio-forms' ), '', array( 'response' => 404 ) ); } $this->snapshot_revision( $form_id, $revisions[ $index ]['def'], sprintf( /* translators: %s: local date and time of the restored version */ __( 'Restored the version from %s', 'holmesportfolio-forms' ), holmesforms_date( mysql2date( 'U', $revisions[ $index ]['t'], false ), true ) ) ); $this->save_definition( $form_id, $revisions[ $index ]['def'] ); wp_safe_redirect( add_query_arg( array( 'page' => self::BUILD_SLUG, 'form' => $form_id, 'holmesforms_notice' => 'restored', ), admin_url( 'admin.php' ) ) ); exit; } /* Builder edit lock */ private static function lock_page_token() { static $token = ''; if ( '' === $token ) { $token = strtolower( wp_generate_password( 16, false, false ) ); } return $token; } public function ajax_lock_check() { check_ajax_referer( 'holmesforms_lock', 'nonce' ); $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) ) { wp_send_json_error(); } $lock_holder = self::other_editor_id( $form_id ); if ( ! $lock_holder ) { wp_set_post_lock( $form_id ); update_post_meta( $form_id, self::LOCK_PAGE_META, isset( $_POST['page'] ) ? sanitize_key( wp_unslash( $_POST['page'] ) ) : '' ); wp_send_json_success( array( 'mine' => true ) ); } wp_send_json_success( array( 'mine' => false, 'who' => self::user_display_name( $lock_holder, __( 'another user', 'holmesportfolio-forms' ) ), ) ); } public function ajax_release_lock() { check_ajax_referer( 'holmesforms_lock', 'nonce' ); $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) ) { wp_send_json_error(); } $lock_value = (string) get_post_meta( $form_id, '_edit_lock', true ); $parts = $lock_value ? explode( ':', $lock_value ) : array(); $lock_holder = isset( $parts[1] ) ? (int) $parts[1] : 0; $page_token = isset( $_POST['page'] ) ? sanitize_key( wp_unslash( $_POST['page'] ) ) : ''; $lock_page = (string) get_post_meta( $form_id, self::LOCK_PAGE_META, true ); if ( $lock_holder && get_current_user_id() === $lock_holder && ( '' === $lock_page || $page_token === $lock_page ) ) { delete_post_meta( $form_id, '_edit_lock' ); delete_post_meta( $form_id, self::LOCK_PAGE_META ); } wp_send_json_success(); } public function ajax_tip_done() { check_ajax_referer( 'holmesforms_tip_done', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error(); } update_user_meta( get_current_user_id(), 'holmesforms_group_tip_done', 1 ); wp_send_json_success(); } private function render_captcha_key_note( $title, array $working, array $unsupported, $footnote ) { echo '

    ' . esc_html( $title ) . '

      '; foreach ( $working as $label ) { echo '
    • ' . esc_html__( 'Works:', 'holmesportfolio-forms' ) . ' ' . esc_html( $label ) . '
    • '; } foreach ( $unsupported as $label ) { echo '
    • ' . esc_html__( 'Not supported:', 'holmesportfolio-forms' ) . ' ' . esc_html( $label ) . '
    • '; } echo '
    '; if ( '' !== $footnote ) { echo '

    ' . esc_html( $footnote ) . '

    '; } echo '
    '; } public function ajax_test_captcha() { if ( ! current_user_can( 'holmesforms_admin' ) || ! check_ajax_referer( 'holmesforms_test_captcha', '_wpnonce', false ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'holmesportfolio-forms' ) ), 403 ); } $settings = (array) get_option( self::SETTINGS, array() ); $provider = isset( $settings['spam_provider'] ) ? sanitize_key( $settings['spam_provider'] ) : 'none'; if ( 'turnstile' === $provider ) { $endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- the provider's own check address, used only by the Test key button $secret = isset( $settings['turnstile_secret'] ) ? trim( HP_Forms_Crypt::decrypt( (string) $settings['turnstile_secret'] ) ) : ''; } elseif ( 'recaptcha' === $provider ) { $endpoint = 'https://www.google.com/recaptcha/api/siteverify'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- the provider's own check address, used only by the Test key button $secret = isset( $settings['recaptcha_secret'] ) ? trim( HP_Forms_Crypt::decrypt( (string) $settings['recaptcha_secret'] ) ) : ''; } else { wp_send_json_error( array( 'message' => __( 'No challenge is switched on.', 'holmesportfolio-forms' ) ) ); } if ( '' === $secret ) { wp_send_json_error( array( 'message' => __( 'No secret key is saved yet.', 'holmesportfolio-forms' ) ) ); } $result = wp_remote_post( $endpoint, array( 'timeout' => self::CAPTCHA_TEST_TIMEOUT_SECONDS, 'user-agent' => 'HolmesPortfolio Forms', 'body' => array( 'secret' => $secret, 'response' => 'hpf-key-test', ), ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => __( 'Could not reach the provider. Check the site can make outgoing requests.', 'holmesportfolio-forms' ) ) ); } $body = json_decode( wp_remote_retrieve_body( $result ), true ); $errors = ( is_array( $body ) && isset( $body['error-codes'] ) ) ? (array) $body['error-codes'] : array(); if ( in_array( 'invalid-input-secret', $errors, true ) || in_array( 'missing-input-secret', $errors, true ) ) { wp_send_json_error( array( 'message' => __( '✕ The provider does not recognise this secret key.', 'holmesportfolio-forms' ) ) ); } if ( 'recaptcha' === $provider ) { if ( ! preg_match( '/^6L[\w-]{38}$/', $secret ) ) { wp_send_json_error( array( 'message' => __( '✕ This does not look like a reCAPTCHA secret key. They start with 6L and are 40 characters long.', 'holmesportfolio-forms' ) ) ); } wp_send_json_success( array( 'unconfirmed' => true, 'message' => __( 'The key has the right shape, but Google only confirms a secret when a real check is passed. Open a form, tick the box, and send it once to prove the pair works.', 'holmesportfolio-forms' ), ) ); } if ( is_array( $body ) && ( ! empty( $body['success'] ) || in_array( 'invalid-input-response', $errors, true ) || in_array( 'timeout-or-duplicate', $errors, true ) ) ) { wp_send_json_success( array( 'message' => __( '✓ The provider accepts this secret key.', 'holmesportfolio-forms' ) ) ); } wp_send_json_error( array( 'message' => __( 'The provider gave an answer this check does not understand.', 'holmesportfolio-forms' ) ) ); } public function handle_take_lock() { $form_id = isset( $_POST['form'] ) ? absint( $_POST['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_take_lock_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } delete_post_meta( $form_id, '_edit_lock' ); wp_set_post_lock( $form_id ); wp_safe_redirect( add_query_arg( array( 'page' => self::BUILD_SLUG, 'form' => $form_id, ), admin_url( 'admin.php' ) ) ); exit; } public function handle_delete_form() { $form_id = isset( $_GET['form'] ) ? absint( $_GET['form'] ) : 0; if ( ! self::is_editable_form( $form_id ) || ! current_user_can( self::CAP ) || ! check_admin_referer( 'holmesforms_delete_form_' . $form_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } global $wpdb; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-storage.php'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- the plugin's own table, prepared id $has_entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id = %d', HP_Forms_Storage::table(), $form_id ) ) > 0; if ( $has_entries && ! apply_filters( 'holmesforms_entry_delete_allowed', true ) ) { wp_die( esc_html__( 'This form has entries, and entries can only be closed on this site, not deleted. Close its entries or ask an admin to change that setting first.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } wp_delete_post( $form_id, true ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } /* Entries export and bulk actions */ public function handle_export() { if ( ! current_user_can( self::CAP_EXPORT ) || ! check_admin_referer( 'holmesforms_export_entries' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'holmesportfolio-forms' ), '', array( 'response' => 403 ) ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-entries.php'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only, the ticked ids; the export nonce was checked above $ids = isset( $_GET['ids'] ) ? array_map( 'absint', (array) wp_unslash( $_GET['ids'] ) ) : array(); $ids = array_values( array_filter( $ids ) ); if ( ! $ids ) { wp_safe_redirect( add_query_arg( 'holmesforms_notice', 'export_none_selected', wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=holmesforms-entries' ) ) ); exit; } HP_Forms_Entries::export_csv( array( 'ids' => $ids ), '', ! empty( $_GET['purge'] ) ); } /* Presets */ public function ajax_presets() { check_ajax_referer( 'holmesforms_presets', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array(), 403 ); } require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-components.php'; require_once HOLMESFORMS_DIR . 'includes/class-hp-forms-presets.php'; $operation = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : 'list'; if ( 'save' === $operation ) { $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : ''; $fields = isset( $_POST['fields'] ) ? json_decode( sanitize_textarea_field( wp_unslash( $_POST['fields'] ) ), true ) : null; if ( '' === $name || ! is_array( $fields ) || ! $fields ) { wp_send_json_error(); } HP_Forms_Presets::save( $name, $fields, ! empty( $_POST['replace'] ) ); } elseif ( 'delete' === $operation ) { HP_Forms_Presets::delete( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '' ); } elseif ( 'lock' === $operation || 'unlock' === $operation ) { HP_Forms_Presets::set_lock( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '', 'lock' === $operation ); } wp_send_json_success( array( 'presets' => HP_Forms_Presets::all( true ) ) ); } /* Builder definition helpers */ private function blank_definition() { return array( 'pages' => array( array( 'id' => 'page-1', 'title' => '', 'visible_when' => null, 'fields' => array(), ), ), 'variables' => array(), ); } private function export_definition( $form_id ) { $pages = get_post_meta( $form_id, HP_Forms_Form::META_PAGES, true ); $variables = get_post_meta( $form_id, HP_Forms_Form::META_VARIABLES, true ); return array( 'pages' => $pages ? $pages : array(), 'variables' => $variables ? $variables : array(), 'debug' => (bool) get_post_meta( $form_id, HP_Forms_Form::META_DEBUG, true ), 'group_inbox' => (string) get_post_meta( $form_id, HP_Forms_Form::META_GROUP_INBOX, true ), 'entry_label' => self::sanitize_entry_label( get_post_meta( $form_id, HP_Forms_Form::META_ENTRY_LABEL, true ) ), 'ref_prefix' => (string) get_post_meta( $form_id, HP_Forms_Form::META_REF_PREFIX, true ), 'bh_region' => HP_Forms_Form::clean_bank_holiday_region( get_post_meta( $form_id, HP_Forms_Form::META_BH_REGION, true ) ), 'case_view' => (string) get_post_meta( $form_id, HP_Forms_Form::META_CASE_VIEW, true ), 'ext' => (array) apply_filters( 'holmesforms_builder_form_ext', array(), $form_id ), ); } private function component_type_list() { $items = array(); foreach ( HP_Forms_Components::types() as $type => $type_info ) { $items[] = array( 'type' => $type, 'label' => $type_info['label'], 'group' => $type_info['group'], 'badge' => isset( $type_info['badge'] ) ? (string) $type_info['badge'] : '', 'addable' => HP_Forms_Components::is_addable( $type ), 'answer' => ! HP_Forms_Components::stores_no_answer( $type ), 'config' => HP_Forms_Components::default_config( $type ), 'preview' => HP_Forms_Components::preview_html( $type ), ); } $items[] = array( 'type' => 'container-if', 'label' => __( 'Conditional group', 'holmesportfolio-forms' ), 'group' => 'layout', 'config' => array(), 'preview' => '' . esc_html__( 'A group shown or hidden by a condition', 'holmesportfolio-forms' ) . '', ); $items = (array) apply_filters( 'holmesforms_palette_items', $items ); return $items; } private function operator_list() { return array( 'eq' => __( 'is', 'holmesportfolio-forms' ), 'neq' => __( 'is not', 'holmesportfolio-forms' ), 'contains' => __( 'contains', 'holmesportfolio-forms' ), 'not_contains' => __( 'does not contain', 'holmesportfolio-forms' ), 'gt' => __( 'is greater than', 'holmesportfolio-forms' ), 'lt' => __( 'is less than', 'holmesportfolio-forms' ), 'before' => __( 'is before', 'holmesportfolio-forms' ), 'after' => __( 'is after', 'holmesportfolio-forms' ), 'empty' => __( 'is empty', 'holmesportfolio-forms' ), 'not_empty' => __( 'is not empty', 'holmesportfolio-forms' ), ); } }