[ '==', '!=', 'null', '!null', 'includes', '!includes', 'startWith', 'endWith', 'matchesPattern', 'doesNotMatchPattern' ], 'text' => [ '==', '!=', 'null', '!null', 'includes', '!includes', 'startWith', 'endWith', 'matchesPattern', 'doesNotMatchPattern' ], 'number' => [ '==', '!=', '>', '>=', '<', '<=', 'between', 'matchesPattern', 'doesNotMatchPattern' ], 'list' => [ '==', '!=', 'in', '!in', 'isSelected', '!isSelected', 'matchesPattern', 'doesNotMatchPattern' ], 'checkbox' => [ 'isChecked', '!isChecked', 'matchesPattern', 'doesNotMatchPattern' ], 'datepicker' => [ 'datePickerIs', 'isBefore', 'isOnOrBefore', 'isAfter', 'isOnOrAfter' ], 'timepicker' => [ 'timePickerIs', 'isBefore', 'isOnOrBefore', 'isAfter', 'isOnOrAfter' ], ]; /** * Source key — one of: cf7, wpforms, gravity, ninja, caldera. * * Subclasses MUST override. * * @var string */ protected $key = ''; /** * Human-readable source name (shown in admin UI). * * Subclasses MUST override. * * @var string */ protected $title = ''; /** * Field labels that did not have a SureForms equivalent during the last import. * * @var array */ protected $unsupported_fields = []; /** * Slugs already used in the current form, for collision avoidance in * `reserve_slug()`. Reset per form by subclasses before calling * `build_form_content()`. * * @var array */ protected $used_slugs = []; /** * Request-scoped cache of the imported-forms map. `null` until first read. * * The map option is `autoload=false`, so reading it once per request (rather * than once per source form) avoids an N+1 DB hit on the listing endpoints. * * @var array|null */ private $imported_map_cache = null; /** * List forms in the source plugin, formatted for the picker UI. * * @since 2.11.0 * @return array> */ public function list_forms() { if ( ! $this->exist() ) { return []; } $out = []; foreach ( $this->get_source_forms() as $form ) { $source_id = $this->get_source_form_id( $form ); $existing_id = $this->find_existing_srfm_id( $source_id ); $out[] = [ 'id' => $source_id, 'name' => $this->get_source_form_name( $form ), 'imported_srfm_id' => $existing_id, 'imported_srfm_edit_url' => $existing_id ? admin_url( 'post.php?post=' . $existing_id . '&action=edit' ) : '', ]; } return $out; } /** * Count the source forms without resolving per-form import mappings. * * The sources listing only needs a count; calling list_forms() there would * run find_existing_srfm_id() for every form purely to discard the result. * * @since 2.11.0 * @return int */ public function count_source_forms() { if ( ! $this->exist() ) { return 0; } return count( $this->get_source_forms() ); } /** * Import (or dry-run) the selected source forms into SureForms. * * Re-imports honour an optional per-source behavior map: * - `update` (default) — overwrite the existing SureForms post. * - `skip` — leave the existing post untouched; report under `skipped`. * - `create` — insert a fresh SureForms post even if one already exists. * * @since 2.11.0 * * @param array $selected_ids List of source form ids. Empty = all. * @param bool $dry_run If true, no posts are inserted; a preview is returned. * @param array $behavior Per-source-id behavior. Keyed by source id (any cast). * @param string $post_status Status for newly inserted forms; one of `draft`/`publish`. * @param bool $skip_existing Force `skip` for any source form already mapped to a * SureForms post — the onboarding "Import all" uses this so it * cannot silently overwrite forms the user already imported + * hand-edited. Per-form $behavior entries still win. * @return array{imported: array>, failed: array, skipped: array>, unsupported_fields: array, preview?: array} */ public function import_forms( array $selected_ids = [], $dry_run = false, array $behavior = [], $post_status = 'publish', $skip_existing = false ) { $post_status = in_array( $post_status, [ 'draft', 'publish' ], true ) ? $post_status : 'publish'; $this->unsupported_fields = []; $result = [ 'imported' => [], 'failed' => [], 'skipped' => [], 'unsupported_fields' => [], ]; if ( ! $this->exist() ) { return $result; } $preview = []; $allowed_actions = [ 'update', 'skip', 'create' ]; foreach ( $this->get_source_forms() as $form ) { $source_id = $this->get_source_form_id( $form ); if ( ! empty( $selected_ids ) && ! in_array( (string) $source_id, array_map( 'strval', $selected_ids ), true ) ) { continue; } $content = $this->build_form_content( $form ); if ( '' === trim( $content ) ) { $result['failed'][] = $this->get_source_form_name( $form ); continue; } // SureForms CPT post_content holds only field blocks at top level. // The submit button is NOT a content block — SureForms auto-renders // it from the `_srfm_submit_button_text` meta (set in get_form_metas), // so we must not append a button block here or the form shows two. $markup = $content; if ( $dry_run ) { $preview[ (string) $source_id ] = $markup; continue; } $metas = $this->get_form_metas( $form ); $existing_id = $this->find_existing_srfm_id( $source_id ); // Resolve the per-form action. Order of precedence: // 1. explicit per-id entry from $behavior (user choice) // 2. `$skip_existing` shortcut when there IS an existing import // 3. default `update` (re-import overwrites — matches Settings UI default). $explicit_action = $behavior[ (string) $source_id ] ?? ( $behavior[ (int) $source_id ] ?? null ); if ( is_string( $explicit_action ) && in_array( $explicit_action, $allowed_actions, true ) ) { $action = $explicit_action; } elseif ( $skip_existing && $existing_id ) { $action = 'skip'; } else { $action = 'update'; } if ( $existing_id && 'skip' === $action ) { $result['skipped'][] = [ 'srfm_id' => $existing_id, 'source_id' => $source_id, 'name' => $this->get_source_form_name( $form ), 'edit_url' => admin_url( 'post.php?post=' . $existing_id . '&action=edit' ), ]; continue; } if ( $existing_id && 'create' !== $action ) { $post_id = $this->update_form_post( $existing_id, $form, $markup, $metas ); } else { $post_id = $this->insert_form_post( $form, $markup, $metas, $post_status ); } if ( $post_id ) { $this->record_import_mapping( $post_id, $source_id ); $result['imported'][] = [ 'srfm_id' => $post_id, 'source_id' => $source_id, 'name' => $this->get_source_form_name( $form ), 'edit_url' => admin_url( 'post.php?post=' . $post_id . '&action=edit' ), ]; } else { $result['failed'][] = $this->get_source_form_name( $form ); } } $result['unsupported_fields'] = array_values( array_unique( array_filter( $this->unsupported_fields ) ) ); if ( $dry_run ) { $result['preview'] = $preview; } return $result; } /** * Source key accessor. * * @since 2.11.0 * @return string */ public function get_key() { return $this->key; } /** * Display title accessor. * * @since 2.11.0 * @return string */ public function get_title() { return $this->title; } /** * Whether the source plugin is currently installed/active. * * @since 2.11.0 * @return bool */ abstract public function exist(); /** * Insert a new sureforms_form post. * * @since 2.11.0 * * @param array $form Source form descriptor (for title). * @param string $markup Block markup for post_content. * @param array $metas Optional meta_input payload (key => value). * @param string $post_status Status for the new post; one of `draft`/`publish`. * @return int Inserted post id, or 0 on failure. */ protected function insert_form_post( array $form, $markup, array $metas = [], $post_status = 'publish' ) { $post_status = in_array( $post_status, [ 'draft', 'publish' ], true ) ? $post_status : 'publish'; $args = [ 'post_type' => SRFM_FORMS_POST_TYPE, 'post_status' => $post_status, 'post_title' => $this->get_source_form_name( $form ), // wp_insert_post applies wp_unslash to post_content; pre-slash so the // JSON unicode escapes (e.g. <) survive the round-trip. 'post_content' => wp_slash( $markup ), ]; if ( ! empty( $metas ) ) { $args['meta_input'] = $metas; } $post_id = wp_insert_post( $args, true ); if ( is_wp_error( $post_id ) ) { return 0; } $post_id = (int) $post_id; // Blocks carry the form's post id in their `formId` attribute, which // SureForms uses at render to resolve per-block conditional-logic classes // (`conditional-trigger`/`conditional-logic`). The id isn't known until // the post exists, so stamp it now and re-save the content. $stamped = $this->apply_form_id_to_blocks( $markup, $post_id ); if ( $stamped !== $markup ) { wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( $stamped ), ] ); } return $post_id; } /** * Update an existing sureforms_form post with re-imported markup. * * @since 2.11.0 * * @param int $post_id Existing post id. * @param array $form Source form descriptor. * @param string $markup New post_content. * @param array $metas Optional meta_input payload (key => value). * @return int The post id on success, 0 on failure. */ protected function update_form_post( $post_id, array $form, $markup, array $metas = [] ) { $args = [ 'ID' => $post_id, 'post_title' => $this->get_source_form_name( $form ), // wp_update_post applies wp_unslash to post_content; pre-slash so the // JSON unicode escapes (e.g. <) survive the round-trip. The form id is // stamped into each block's `formId` so SureForms can resolve per-block // conditional-logic classes at render. 'post_content' => wp_slash( $this->apply_form_id_to_blocks( $markup, (int) $post_id ) ), ]; if ( ! empty( $metas ) ) { $args['meta_input'] = $metas; } $updated = wp_update_post( $args, true ); return is_wp_error( $updated ) ? 0 : (int) $updated; } /** * Stamp the form's post id into every SureForms block's `formId` attribute. * * SureForms resolves per-block conditional-logic classes at render from each * block's `formId` (see `Base::$conditional_class`). Migrated markup is built * before the post exists, so the id is injected once it's known. * * Uses a targeted string insert rather than parse_blocks()/serialize_blocks() * on purpose: re-serialising would drop the JSON_HEX escaping the * Block_Templates emitters apply to neutralise hostile labels. Every srfm * block carries at least a `block_id`, so the opening `{` is always followed * by a quoted key — `formId` is inserted ahead of it without touching the * existing (already-escaped) attribute payload. * * @since 2.11.0 * * @param string $markup Serialized block markup. * @param int $form_id Target form post id. * @return string */ protected function apply_form_id_to_blocks( $markup, $form_id ) { $form_id = (int) $form_id; if ( $form_id <= 0 || '' === trim( (string) $markup ) ) { return (string) $markup; } return (string) preg_replace( '/(