From 10bc2e38e5658477171af8db8e3561b6e1c59af4 Mon Sep 17 00:00:00 2001 From: Rahul Verma Date: Sun, 24 May 2026 21:51:15 +0530 Subject: [PATCH 01/92] feat: add form migration framework with Contact Form 7 importer P1 foundation: REST endpoints, abstract Base_Migrator with idempotent re-imports and unsupported-field tracking, a block-markup emitter for each SureForms field block, a full Contact Form 7 importer (shortcode parser plus admin notification and confirmation metadata stub), and a four-step Migration tab inside SureForms -> Settings. WPForms, Gravity Forms, Ninja Forms, and Caldera Forms importers are planned for P2 and P3. See docs/FORM_MIGRATION_PLAN.md for the detailed spec and docs/FORM_MIGRATION_OVERVIEW.md for a plain-language summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/FORM_MIGRATION_OVERVIEW.md | 77 +++ docs/FORM_MIGRATION_PLAN.md | 459 +++++++++++++++ inc/migrator/base-migrator.php | 391 +++++++++++++ inc/migrator/block-templates.php | 496 ++++++++++++++++ inc/migrator/bootstrap.php | 266 +++++++++ inc/migrator/importers/cf7-importer.php | 540 ++++++++++++++++++ plugin-loader.php | 6 + src/admin/settings/Component.js | 6 + src/admin/settings/Navigation.js | 12 + src/admin/settings/migration/DryRunPreview.js | 222 +++++++ src/admin/settings/migration/FormSelector.js | 220 +++++++ src/admin/settings/migration/ImportResult.js | 149 +++++ src/admin/settings/migration/MigrationPage.js | 115 ++++ src/admin/settings/migration/SourcePicker.js | 164 ++++++ src/admin/settings/migration/api.js | 48 ++ 15 files changed, 3171 insertions(+) create mode 100644 docs/FORM_MIGRATION_OVERVIEW.md create mode 100644 docs/FORM_MIGRATION_PLAN.md create mode 100644 inc/migrator/base-migrator.php create mode 100644 inc/migrator/block-templates.php create mode 100644 inc/migrator/bootstrap.php create mode 100644 inc/migrator/importers/cf7-importer.php create mode 100644 src/admin/settings/migration/DryRunPreview.js create mode 100644 src/admin/settings/migration/FormSelector.js create mode 100644 src/admin/settings/migration/ImportResult.js create mode 100644 src/admin/settings/migration/MigrationPage.js create mode 100644 src/admin/settings/migration/SourcePicker.js create mode 100644 src/admin/settings/migration/api.js diff --git a/docs/FORM_MIGRATION_OVERVIEW.md b/docs/FORM_MIGRATION_OVERVIEW.md new file mode 100644 index 000000000..dd7fac4d6 --- /dev/null +++ b/docs/FORM_MIGRATION_OVERVIEW.md @@ -0,0 +1,77 @@ +# SureForms — Form Migration (Overview) + +> Looking for the full technical plan? See `FORM_MIGRATION_PLAN.md` in this folder. + +## What this feature does + +Lets users move their existing forms into SureForms from the other popular form-builder plugins, in one click — no copy-pasting, no rebuilding. + +## Which plugins we'll support + +| Source plugin | Approx. user base | Status | +|---|---|---| +| Contact Form 7 | 10M+ sites | **Available in v1** | +| WPForms | 6M+ sites | Coming next | +| Gravity Forms | ~650K sites | Coming next | +| Ninja Forms | 600K+ sites | Later release | +| Caldera Forms | ~50K sites (unmaintained plugin) | Later release | + +Combined audience: roughly **17 million sites** that could move to SureForms. + +## What an import gives you + +When a user clicks "Import", we bring across: + +1. **The form's fields** — text, email, phone, dropdown, checkbox, radio, file URL, number, textarea, consent checkboxes, etc. They show up as native SureForms blocks, ready to edit in the Gutenberg editor. +2. **The form's title** — same as the original. +3. **A working admin notification email** — recipient is the site admin, subject mentions the form name, body uses `{all_data}` so submissions go straight to your inbox. +4. **A success message** — a clean "Thank you" confirmation shown after submission. + +The imported form works on day one. Users can polish it in SureForms' editor. + +## What doesn't transfer yet (and why) + +| Thing | Why not | +|---|---| +| File-upload fields | SureForms doesn't have a file-upload block yet — coming separately. | +| Quiz / signature / rating fields | No SureForms equivalent yet. | +| The exact email-body text from CF7 | CF7 uses a different placeholder syntax (`[your-name]` vs SureForms' smart tags). Trying to translate it gets unreliable, so we ship a clean default instead. WPForms and Gravity emails will come through fully when those are added. | +| Multi-step / page breaks | Waiting on a SureForms multi-step block. | + +When a field can't be migrated, the import screen lists it under **"Some fields were skipped"** so the user knows what to add by hand. + +## How users will find it + +A new **Migration** tab inside **SureForms → Settings**, at the bottom of the sidebar. The workflow is four short steps: + +1. **Choose a source** — tiles show each detected plugin and how many forms it has. +2. **Pick which forms** — checkbox list; "Select all" if you want everything. +3. **Preview** — see what the migrated form will look like before anything is saved. +4. **Confirm & import** — done. The result screen has direct "Edit in SureForms" links. + +If a user re-runs the import for the same source form, we **update** the existing SureForms version instead of creating a duplicate. + +## What's already built + +- The Migration tab is live in **SureForms → Settings → Migration** +- Contact Form 7 imports work end to end (fields + notification + confirmation) +- "Preview before importing" works — nothing gets saved until the user confirms +- Skipped fields are surfaced clearly to the user + +## What's next + +1. **WPForms importer** — including its full email-notification and confirmation rules +2. **Gravity Forms importer** — including notifications, scheduling, entry limits +3. **Ninja Forms + Caldera Forms importers** +4. **Entry migration** — bringing across historical form submissions (capped at 1,000 per form to keep imports fast) +5. **Filling in field gaps** — once SureForms ships file-upload, multi-step, and signature blocks, the migrator will start carrying those over too + +## Why we chose this approach + +We modelled the migrator on **Fluent Forms**' approach, which has been running in production on 700K+ sites for years. Same flow (source → forms → preview → import), same safety net (re-imports update rather than duplicate), and the same honest stance on edge cases like CF7 email templates. + +## Where to find things (for engineers) + +- Full technical plan with code structure, REST endpoints, field-mapping tables: `FORM_MIGRATION_PLAN.md` +- PHP code: `inc/migrator/` +- Admin tab UI: `src/admin/settings/migration/` diff --git a/docs/FORM_MIGRATION_PLAN.md b/docs/FORM_MIGRATION_PLAN.md new file mode 100644 index 000000000..48306197b --- /dev/null +++ b/docs/FORM_MIGRATION_PLAN.md @@ -0,0 +1,459 @@ +# SureForms — Form Migration Implementation Plan + +## 1. Goal + +Build a one-click migrator that imports forms (and optionally entries) from the 5 major WordPress form plugins into SureForms, modelled on Fluent Forms' production-tested migrator architecture (~5,400 LOC across 5 sources, deployed on 700K+ sites). + +**Sources to support in v1**: Contact Form 7, WPForms (Lite + Pro), Gravity Forms, Ninja Forms, Caldera Forms. + +## 2. Why Fluent Forms' pattern + +Fluent's full migrator source was read end-to-end (`fluentform/app/Services/Migrator/`). Their architecture is: + +- **One abstract base class** with the import loop, idempotency map, and a ~1,200-line canonical field dictionary. +- **Thin per-source subclasses** (500–900 LOC each) that only do (a) `source_type → fluent_id` mapping and (b) source-specific args formatting. +- **4 AJAX endpoints** for: list sources, list forms in source, import forms, import entries. +- **Template Method pattern** — base class drives the flow, subclasses fill in 7 abstract methods (`exist`, `getForms`, `getFields`, `getFormName`, `getFormMetas`, `getFormsFormatted`, `getFormId`). + +It's clean, proven, and easy to extend. SureForms will adopt the same shape, adapted for: + +1. **REST instead of AJAX** — SureForms already uses `/sureforms/v1/` namespace (`inc/rest-api.php`); AJAX would be inconsistent. +2. **Block markup output instead of JSON** — SureForms forms are Gutenberg block strings in `post_content` of the `sureforms_form` CPT, not JSON in a custom table. So the importer's final stage is to serialize block markup, not assemble a flat field array. +3. **15 SureForms blocks as targets** — instead of Fluent's ~25 field types. + +## 3. Sources to import — confirmed field type maps + +These are lifted directly from Fluent's `fieldTypes()` methods in each migrator class. Total source field types we need to handle: **110** (with overlap). + +### 3.1 Contact Form 7 (13 form-tag types → SureForms blocks) +Source: `wpcf7_contact_form` post type, `_form` meta (shortcode template). + +| CF7 form-tag | SureForms block | Notes | +|---|---|---| +| `text`, `text*` | `srfm/input` | type=text | +| `email`, `email*` | `srfm/email` | | +| `url`, `url*` | `srfm/url` | | +| `tel`, `tel*` | `srfm/phone` | | +| `number`, `number*` | `srfm/number` | | +| `range` | `srfm/number` | slider mode | +| `date` | `srfm/input` | type=date (no native date block) | +| `textarea`, `textarea*` | `srfm/textarea` | | +| `select`, `select*` | `srfm/dropdown` | `multiple` → multi-select | +| `checkbox`, `checkbox*` | `srfm/checkbox` | | +| `radio` | `srfm/multi-choice` | | +| `acceptance` | `srfm/gdpr` | tnc_html → content | +| `file`, `file*` | unsupported in v1 | no native `srfm/file` — flag | +| `quiz`, `captchar`, `hidden` | skipped | legacy / not in v1 scope | + +Detection: `defined('WPCF7_PLUGIN')`. Entries: requires Flamingo plugin. + +### 3.2 WPForms (22 field types → SureForms blocks) +Source: CPT `wpforms`, field data as JSON in `post_content`. + +| WPForms type | SureForms block | Notes | +|---|---|---| +| `text` | `srfm/input` | | +| `email` | `srfm/email` | | +| `url` | `srfm/url` | | +| `phone` | `srfm/phone` | | +| `number`, `number-slider` | `srfm/number` | | +| `textarea`, `richtext` | `srfm/textarea` | rich → fallback | +| `select`, `multi_select` | `srfm/dropdown` | | +| `radio` | `srfm/multi-choice` | | +| `checkbox` | `srfm/checkbox` | | +| `name` | `srfm/input × 3` | split first/middle/last | +| `address` | `srfm/address` | structured fields | +| `date-time` | `srfm/input` | type=date until native block | +| `password` | `srfm/input` | type=password | +| `hidden` | `srfm/input` | type=hidden | +| `html`, `divider` | structural — core/html or section break | | +| `pagebreak` | unsupported in v1 | no native multi-step yet | +| `file-upload`, `rating`, `layout` | unsupported in v1 | flag | + +Detection: `function_exists('wpforms')`. Entries: WPForms Pro only. + +### 3.3 Gravity Forms (22 field types → SureForms blocks) +Source: custom tables `gf_form` + `gf_form_meta` (serialized PHP array). + +| Gravity type | SureForms block | Notes | +|---|---|---| +| `email` | `srfm/email` | | +| `text` | `srfm/input` | | +| `name` | `srfm/input × 3` | first/middle/last sub-inputs | +| `hidden` | `srfm/input` (type=hidden) | | +| `textarea` | `srfm/textarea` | | +| `website` | `srfm/url` | | +| `phone` | `srfm/phone` | | +| `select`, `multiselect` | `srfm/dropdown` | | +| `checkbox` | `srfm/checkbox` | | +| `radio` | `srfm/multi-choice` | | +| `date`, `time` | `srfm/input` (type=date/time) | | +| `number` | `srfm/number` | | +| `address` | `srfm/address` | | +| `consent` | `srfm/gdpr` | | +| `captcha` | unsupported | external service | +| `html` | core/html (or skip) | | +| `section` | section break / divider | | +| `page` | unsupported in v1 | multi-step | +| `list` | unsupported in v1 | repeater not in srfm | +| `fileupload` | unsupported in v1 | no srfm/file | + +Detection: `class_exists('GFForms')`. Entries: `gf_entry`, `gf_entry_meta` tables. + +### 3.4 Ninja Forms (27 field type aliases → SureForms blocks) +Source: custom tables `nf3_forms`, `nf3_fields`, `nf3_field_meta` (JOIN-based). + +| Ninja type | SureForms block | Notes | +|---|---|---| +| `email` | `srfm/email` | | +| `textbox`, `firstname`, `lastname`, `address`, `city`, `zip` | `srfm/input` | (Ninja splits address into individual text fields) | +| `liststate`, `listcountry` | `srfm/dropdown` | | +| `textarea` | `srfm/textarea` | | +| `phone` | `srfm/phone` | | +| `select`, `listselect` | `srfm/dropdown` | | +| `listmultiselect` | `srfm/dropdown` (multi) | | +| `radio`, `listradio`, `listimage`, `toggle_switch` | `srfm/multi-choice` | | +| `checkbox`, `listcheckbox` | `srfm/checkbox` | | +| `date` | `srfm/input` (type=date) | | +| `number` | `srfm/number` | | +| `hidden` | `srfm/input` (type=hidden) | | +| `html` | core/html | | +| `hr` | section divider | | +| `starrating` | unsupported in v1 | no srfm/rating | +| `repeater` | unsupported in v1 | | +| `recaptcha` | spam protection — emit as form-level setting | | +| `submit` | handled by `srfm/inline-button` | | + +Detection: `defined('NF_SERVER_URL')`. Entries: `nf_sub` CPT. + +### 3.5 Caldera Forms (26 field types → SureForms blocks) +Source: `Caldera_Forms_Forms::get_forms()` static API. Caldera is unmaintained — migration is a one-shot rescue. + +| Caldera type | SureForms block | Notes | +|---|---|---| +| `email` | `srfm/email` | | +| `text` | `srfm/input` | | +| `hidden` | `srfm/input` (type=hidden) | | +| `textarea`, `paragraph`, `wysiwyg` | `srfm/textarea` | | +| `url` | `srfm/url` | | +| `phone`, `phone_better` | `srfm/phone` | | +| `select`, `dropdown` | `srfm/dropdown` | | +| `filtered_select2` | `srfm/dropdown` (multi) | | +| `radio`, `toggle_switch` | `srfm/multi-choice` | | +| `checkbox` | `srfm/checkbox` | | +| `date_picker`, `date` | `srfm/input` (type=date) | | +| `number`, `range`, `calculation` | `srfm/number` | | +| `range_slider` | `srfm/number` (slider) | | +| `gdpr` | `srfm/gdpr` | | +| `section_break`, `html` | structural | | +| `color_picker`, `star_rating`, `file`, `cf2_file`, `advanced_file` | unsupported in v1 | flag | +| `button` | handled by `srfm/inline-button` | | + +Detection: `defined('CFCORE_VER')`. Caldera abandoned in 2022 — entries critical. + +## 4. SureForms target blocks (the destination schema) + +From `find sureforms/inc/blocks/*/block.json` — **15 blocks** total: + +| Block | Type | Used for | +|---|---|---| +| `srfm/form` | wrapper | Outer form container | +| `srfm/input` | field | Text/email/url/phone/password/date/hidden (HTML type via attr) | +| `srfm/email` | field | Email input | +| `srfm/textarea` | field | Paragraph text | +| `srfm/url` | field | URL input | +| `srfm/number` | field | Numeric input + slider mode | +| `srfm/phone` | field | International phone | +| `srfm/dropdown` | field | Select / multi-select | +| `srfm/multi-choice` | field | Radio / multiple choice | +| `srfm/checkbox` | field | Checkbox group | +| `srfm/address` | field | Structured address (line1/2/city/state/zip/country) | +| `srfm/gdpr` | field | Consent checkbox | +| `srfm/payment` | field | Stripe payment | +| `srfm/payment-history` | display | Past payments table | +| `srfm/inline-button` | structural | Submit / action button | + +**Gaps acknowledged**: no native rich-text, file upload, signature, rating, date-picker, multi-step (page break), or repeater blocks. Fields mapped to these in source plugins are flagged as `unsupported_fields[]` in the v1 response and surfaced in the import UI as warnings. They will be added in subsequent releases. + +## 5. Code layout (mirrors Fluent's, adapted to SureForms conventions) + +``` +sureforms/inc/migrator/ +├── class-migrator-bootstrap.php # ~200 LOC — REST routes, capability checks, factory +├── class-base-migrator.php # ~800 LOC — abstract import loop + idempotency + helpers +├── class-block-templates.php # ~600 LOC — canonical srfm/* block markup templates +├── importers/ +│ ├── class-cf7-importer.php # ~600 LOC — shortcode regex parser +│ ├── class-wpforms-importer.php # ~700 LOC — JSON decode +│ ├── class-gravity-importer.php # ~800 LOC — serialized array parser +│ ├── class-ninja-importer.php # ~600 LOC — JOIN across nf3_* tables +│ └── class-caldera-importer.php # ~500 LOC — Caldera static API +└── views/admin-page.php # ~150 LOC — admin page mount point + +sureforms/src/admin/migrator/ # React UI (~800 LOC) +├── MigratorApp.jsx # Top-level component +├── SourceList.jsx # Tile per detected source +├── FormSelector.jsx # Pick forms to import +├── ImportPreview.jsx # Dry-run rendered output +├── ImportResult.jsx # Success + unsupported warnings +└── EntryImporter.jsx # Per-form entry import +``` + +**Total estimate: ~5,750 LOC** for all 5 sources (close to Fluent's ~5,400 LOC, with the extra slack accounting for SureForms' block-markup serialization). + +## 6. REST API design + +Namespace: `/wp-json/sureforms/v1/migrator/` (extending the existing `/sureforms/v1/` namespace from `inc/rest-api.php`). + +| Method | Route | Body / Query | Returns | +|---|---|---|---| +| GET | `/migrator/sources` | — | `[{key, title, installed, form_count, supports_entries}]` | +| GET | `/migrator/sources/{key}/forms` | — | `[{id, name, field_count, imported_srfm_id, last_imported_at}]` | +| POST | `/migrator/sources/{key}/import` | `{form_ids: [], dry_run: bool, field_overrides: {}}` | `{imported: [{srfm_id, edit_url, source_id}], unsupported_fields: [], block_markup_preview?: str}` | +| POST | `/migrator/sources/{key}/forms/{id}/entries` | `{limit?: 1000}` | `{count_imported, count_skipped, count_total}` | + +**Security model** (per `sureforms/CLAUDE.md`): +- Every route's `permission_callback` requires `current_user_can('manage_options')`. +- REST cookie nonce (`X-WP-Nonce`) for all routes — these are admin-only, no public access. +- Sanitize `form_ids` via `array_map('absint', ...)`, `key` via allowlist `['cf7','wpforms','gravity','ninja','caldera']`. + +## 7. Canonical block template dictionary + +Analogue of Fluent's 1,200-line `defaultFieldConfig()` array, but emitting Gutenberg block markup strings instead of field-config arrays. + +One method per target block in `class-block-templates.php`: + +```php +final class SRFM_Block_Templates { + + public static function input( array $args ): string { + $defaults = [ + 'label' => '', + 'placeholder' => '', + 'required' => false, + 'field_type' => 'text', // text|email|number|password|date|hidden + 'help_text' => '', + 'default_val' => '', + 'min_length' => '', + 'max_length' => '', + 'css_class' => '', + ]; + $attrs = wp_parse_args( $args, $defaults ); + // Match the schema in src/blocks/input/block.json + $json = wp_json_encode( self::strip_empty( [ + 'label' => $attrs['label'], + 'placeholder' => $attrs['placeholder'], + 'required' => $attrs['required'], + 'fieldType' => $attrs['field_type'], + 'help' => $attrs['help_text'], + 'defaultValue' => $attrs['default_val'], + 'minLength' => $attrs['min_length'], + 'maxLength' => $attrs['max_length'], + 'className' => $attrs['css_class'], + ] ) ); + return "\n"; + } + + public static function email( array $args ): string { /* ... */ } + public static function dropdown( array $args ): string { /* ... */ } + public static function checkbox( array $args ): string { /* ... */ } + // ... one per srfm/* block + + public static function form_wrapper( string $inner_content ): string { + return "\n{$inner_content}\n"; + } +} +``` + +The base migrator's import loop concatenates the per-field strings, wraps them in `form_wrapper()`, and persists via `wp_insert_post(['post_type' => 'sureforms_form', 'post_content' => $markup])`. + +## 8. Base migrator contract (abstract methods) + +```php +abstract class SRFM_Base_Migrator { + + protected string $key; // 'cf7' | 'wpforms' | 'gravity' | 'ninja' | 'caldera' + protected string $title; // Display name + protected array $unsupported_fields = []; + + // Per-source plumbing + abstract public function exist(): bool; + abstract protected function get_forms(): array; + abstract protected function get_form_id( $source_form ); + abstract protected function get_form_name( $source_form ): string; + abstract protected function get_fields( $source_form ): string; // concatenated block markup + abstract protected function get_form_metas( $source_form ): array; // notifications, confirmations + abstract protected function get_entries( $source_form_id ): array; + + // Shared (in base): + public function import_forms( array $selected_ids, bool $dry_run = false ): array; + public function get_forms_formatted(): array; + public function is_already_imported( $source_form ): int|false; + protected function record_import_mapping( int $srfm_id, $source_id ): void; + protected function map_options( array $source_options ): array; + protected function migrate_files( array $urls ): array; +} +``` + +## 9. Idempotent re-imports + +Same pattern as Fluent: a single `wp_option('srfm_imported_forms_map')` storing: + +```php +[ + 123 => [ 'source_id' => 'cf7-45', 'source_key' => 'cf7', 'last_imported' => '2026-05-22 10:00:00' ], + 124 => [ 'source_id' => '8', 'source_key' => 'wpforms', 'last_imported' => '2026-05-22 10:01:00' ], +] +``` + +`is_already_imported()`: +1. Look up `source_key` + `source_id` in the map. +2. If found and the matching `sureforms_form` post still exists → **update** (not insert). +3. If the SureForms form was deleted → prune the stale entry, fresh insert. + +## 10. Unsupported field tracking + +Same pattern as Fluent: +- `$this->unsupported_fields[]` is pushed each time a source field's type isn't in the source's `field_type_map`, capturing the field's user-facing label (not internal id). +- Returned in the REST response as `unsupported_fields: []`. +- Admin UI renders these as a yellow callout: "These fields couldn't be migrated to SureForms — add them manually after import: Field A, Field B, Field C." + +## 11. Entry migration + +- **Form-by-form, not bulk** — same as Fluent. +- **Hard cap of 1,000 entries per form**, override via the `srfm/entry_migration_max_limit` filter. +- **Idempotent**: import deletes existing SureForms entries for that form first (via the same `resetEntries()` pattern Fluent uses) before inserting. +- **Files**: copy via `WP_Filesystem_Direct::copy()` into `wp-content/uploads/sureforms/migrated/`. + +## 12. Admin UI flow + +Lives at **WordPress → SureForms → Tools → Migrator** (new sub-tab next to existing export/import). + +``` +[ Step 1: Select source ] Detected plugins shown as tiles with logo + install count + ▼ click "Migrate from Contact Form 7" + +[ Step 2: Select forms ] Table of forms in source (name | fields | already-imported badge) + ▼ bulk select + "Preview" + +[ Step 3: Dry-run preview ] Server returns block markup; UI renders in a code pane + + warning callout for unsupported fields + ▼ "Confirm & Import" + +[ Step 4: Result ] "Imported X forms." Links: [Edit in SureForms] [Import entries] + Unsupported-fields list (if any) +``` + +The dry-run preview is the **one improvement over Fluent's UX** — it answers the user's "is this going to break anything?" anxiety before they commit. + +## 13. Implementation phasing + +Four mergeable PRs, each ~7–10 working days: + +| Phase | Deliverable | LOC | +|---|---|---| +| **P1: Foundation** | `Base_Migrator`, `Block_Templates`, REST routes, admin page shell, CF7 importer end-to-end | ~2,400 | +| **P2: WPForms + Gravity** | Two more importers + dry-run preview UI | ~1,700 | +| **P3: Ninja + Caldera** | Final two importers + entry import flow | ~1,300 | +| **P4: Polish** | Field-mapping overrides, entry import UI, docs, E2E tests | ~350 | + +Total: ~5,750 LOC delivered across ~4–5 weeks. + +## 14. Testing approach + +- **Unit tests** (PHPUnit, per `composer test`): per importer, fixture-driven — store sample source-form payloads in `tests/fixtures/migrator/{source}/*.json` and assert the produced block markup string matches a golden snapshot. +- **Integration tests**: a real CF7 / WPForms / Gravity form created via their PHP APIs in a `tests/Integration/Migrator/` test, imported via `Base_Migrator::import_forms()`, then assert a `sureforms_form` post exists with the right block content. +- **Playwright E2E** (per `npm run play:run`): full admin flow — open Migrator, pick CF7, select a form, dry-run, confirm import, verify the new form renders correctly on the front-end. +- **Manual exploratory**: all 5 source plugins are already installed locally, so the test environment is real, not mocked. + +## 15. Verification (when is this done) + +1. All 5 importers exist under `sureforms/inc/migrator/importers/` and pass their PHPUnit fixtures. +2. REST endpoints respond correctly: `GET /sureforms/v1/migrator/sources` lists the 5 sources with `installed: true` for whichever plugins are active. +3. Admin page at SureForms → Tools → Migrator renders the tile-based source picker. +4. Dry-run for a non-trivial CF7 form (text + email + textarea + radio + acceptance) produces valid Gutenberg block markup that renders correctly in the SureForms editor. +5. Re-importing the same source form updates the existing SureForms form (not a duplicate). +6. Entry migration imports up to 1,000 entries for a WPForms Pro form and the entries appear in SureForms' Entries admin. +7. Unsupported-field warnings appear in the UI for source fields without a SureForms target (e.g. CF7 `[file]`, WPForms `rating`). +8. PHPStan level 9 passes (`composer phpstan`) and PHPCS passes (`composer lint`). + +## 16. Reference source files (Fluent Forms — production reference) + +| Fluent file | Our equivalent | Why useful | +|---|---|---| +| `app/Services/Migrator/Bootstrap.php` | `class-migrator-bootstrap.php` | AJAX → REST route mapping | +| `app/Services/Migrator/Classes/BaseMigrator.php` | `class-base-migrator.php` | Import loop, idempotency, file migration helpers | +| `app/Services/Migrator/Classes/ContactForm7Migrator.php` | `class-cf7-importer.php` | Battle-tested shortcode regex parser (`formatAsFluentField()`) | +| `app/Services/Migrator/Classes/WpFormsMigrator.php` | `class-wpforms-importer.php` | JSON-decode + 22-entry field map | +| `app/Services/Migrator/Classes/GravityFormsMigrator.php` | `class-gravity-importer.php` | Serialized-array parsing + Name/Address sub-input handling | +| `app/Services/Migrator/Classes/NinjaFormsMigrator.php` | `class-ninja-importer.php` | JOIN across `nf3_*` tables via Ninja's PHP API | +| `app/Services/Migrator/Classes/CalderaMigrator.php` | `class-caldera-importer.php` | `Caldera_Forms_Forms` static API | + +Fluent Forms is GPL-2.0+ — we can lift regexes and parsing patterns directly with attribution comments. The `defaultFieldConfig()` array structure should be re-written for SureForms blocks, not copied (different output format). + +## 16a. Metadata migration + +Form metadata (email notifications, confirmation messages, compliance flags, integrations) lives **outside** the block-markup pipeline — it's persisted as `sureforms_form` post meta. The migrator handles it via the abstract `Base_Migrator::get_form_metas()` method which returns a `meta_key => meta_value` array passed to `wp_insert_post()` / `wp_update_post()` via `meta_input`. + +### What every importer must produce + +A SureForms-shaped payload. The two keys every importer should populate are: + +| SureForms meta key | Schema (registered in `inc/post-types.php`) | +|---|---| +| `_srfm_email_notification` | Array of objects: `{id, status, is_raw_format, name, email_to, email_reply_to, from_name, from_email, email_cc, email_bcc, subject, email_body}` | +| `_srfm_form_confirmation` | Array of objects: `{id, confirmation_type, page_url, custom_url, message, submission_action, enable_query_params, query_params}` | + +Other meta keys exist (`_srfm_compliance`, `_srfm_conditional_logic`, `_srfm_forms_styling`, `_srfm_integrations_webhooks`) but are not required for a usable imported form — they default to SureForms' built-in `register_post_meta` defaults if not provided. + +### What each source can give us (and what we ship in v1) + +| Source | Notifications | Confirmations | Other meta | Status in v1 | +|---|---|---|---|---| +| **CF7** | `_mail`, `_mail_2` use `[field-name]` shortcodes — incompatible with SureForms smart tags | `_messages` array (success/error text) | None | **Stub only** — admin notification to `{admin_email}` with `{all_data}` body, generic success message. Form is usable; users can refine in Single Form Settings. (Matches Fluent's CF7 approach for the same reason.) | +| **WPForms** | `settings.notifications[]` — fully structured (subject/body/recipient/CC/BCC/replyTo/fromName) | `settings.confirmations[]` — multi-confirmation with conditional logic | Webhooks | **Full migration planned in P2** | +| **Gravity** | Notifications, conditional notifications, routing | Confirmations, scheduling, login-required, entry limits | Advanced validation | **Full migration planned in P2** | +| **Ninja** | `Actions` API — type `email`, `successmessage`, `redirect`, `save` | Same Actions API | — | **Full migration planned in P3** | +| **Caldera** | `mailer` config in form settings | `successMessage` field | — | **Full migration planned in P3** | + +### Why CF7 is special + +CF7's email body uses `[your-name]`, `[your-email]` style shortcodes that reference the form-tag `name` attribute. Translating these to SureForms' `{slug_field_id}` smart-tag system requires either (a) maintaining a per-form name→slug mapping during import, or (b) rewriting the body string with regex substitutions that match the field tokens we emitted. Both approaches add ~400 LOC and break easily on edge cases (multi-line bodies, mixed shortcodes/text, CF7 special mail tags like `[_remote_ip]`). + +Fluent Forms — with 700K+ installs and 5+ years of migrator iteration — landed on the same stub-only conclusion for CF7. v1 matches that decision; if user demand surfaces, CF7 mail-template translation is a separate scoped feature. + +### Code pointers (live) + +- **Abstract method**: `Base_Migrator::get_form_metas()` — `inc/migrator/base-migrator.php` +- **Wire-up**: `Base_Migrator::insert_form_post()` / `update_form_post()` both accept a `$metas` arg and pass it to `wp_insert_post()` via `meta_input` +- **CF7 implementation**: `Cf7_Importer::get_form_metas()` — `inc/migrator/importers/cf7-importer.php` +- **SureForms meta schemas** (the contract we have to match): `inc/post-types.php` lines 911-1010 (notification), 1086-1182 (confirmation) + +### Verification + +After importing a CF7 form via the Migration tab, open the resulting SureForms form's settings: +- **Email Notifications** tab → should show one enabled notification named "Admin Notification Email" with subject "New submission: {form title}" and body `{all_data}`. +- **Form Confirmation** tab → should show a centered "Thank you" success message, "Same page" confirmation type. + +If those panels are empty, the meta payload didn't persist — check `wp_options.srfm_imported_forms_map` and `wp_postmeta` for the form id. + +## 17. Out of scope for v1 + +- Polls / Quizzes (Forminator, Gravity surveys) — no SureForms equivalent. +- Formidable Views — front-end data display is its own feature. +- WS Form — niche, defer. +- Multi-step / page break preservation — wait for native `srfm/page-break` block. +- File-upload, signature, rating, repeater fields — wait for native blocks (flagged as unsupported until then). +- Bulk source-selection ("import everything from everywhere") — one source at a time in v1. + +## 18. Sources verified live (May 2026) + +All wordpress.org plugin pages were fetched live during research: + +- [Contact Form 7](https://wordpress.org/plugins/contact-form-7/) — 10M+ installs, v6.1.6 +- [WPForms Lite](https://wordpress.org/plugins/wpforms-lite/) — 6M+ installs, v1.10.0.5 +- [Gravity Forms docs](https://docs.gravityforms.com/form-fields/) — ~655K live sites, commercial +- [Ninja Forms](https://wordpress.org/plugins/ninja-forms/) — 600K+ installs, v3.14.4 +- Caldera Forms — unmaintained since 2022, ~50K legacy sites + +**Combined migration pool: ~17.3M active sites** — the addressable target for v1. diff --git a/inc/migrator/base-migrator.php b/inc/migrator/base-migrator.php new file mode 100644 index 000000000..f28ca04e8 --- /dev/null +++ b/inc/migrator/base-migrator.php @@ -0,0 +1,391 @@ + + */ + protected $unsupported_fields = []; + + /** + * Whether the source plugin is currently installed/active. + * + * @since x.x.x + * @return bool + */ + abstract public function exist(); + + /** + * Return the list of source forms, normalized to ['id', 'name', ...]. + * + * Each element is treated opaquely by the base class and passed back to + * the subclass in `get_source_form_id`, `get_source_form_name`, + * `build_form_content`. + * + * @since x.x.x + * @return array> + */ + abstract protected function get_source_forms(); + + /** + * Return the source-side identifier for a given form item. + * + * @since x.x.x + * + * @param array $form Source form descriptor. + * @return int|string + */ + abstract protected function get_source_form_id( array $form ); + + /** + * Return the source-side display name for a given form item. + * + * @since x.x.x + * + * @param array $form Source form descriptor. + * @return string + */ + abstract protected function get_source_form_name( array $form ); + + /** + * Build the inner block markup for one source form. + * + * Implementations should append field labels that fail to map onto + * SureForms blocks to `$this->unsupported_fields` so the admin UI can + * warn the user. + * + * @since x.x.x + * + * @param array $form Source form descriptor. + * @return string Concatenated field-block markup (without form wrapper). + */ + abstract protected function build_form_content( array $form ); + + /** + * Build the SureForms post-meta payload for one source form. + * + * Returns a map of `meta_key => meta_value` that will be passed to + * `wp_insert_post()` / `wp_update_post()` via `meta_input`. Keys should + * be SureForms' canonical meta keys (e.g. `_srfm_email_notification`, + * `_srfm_form_confirmation`). Values must already match the schemas + * registered in `inc/post-types.php` — the sanitize_callback there will + * still run on import. + * + * Subclasses without source-side metadata (or where the source format + * is incompatible — e.g. CF7) should return a sensible default that + * leaves the imported form usable. + * + * @since x.x.x + * + * @param array $form Source form descriptor. + * @return array Meta key → meta value. + */ + abstract protected function get_form_metas( array $form ); + + /** + * List forms in the source plugin, formatted for the picker UI. + * + * @since x.x.x + * @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 ); + $out[] = [ + 'id' => $source_id, + 'name' => $this->get_source_form_name( $form ), + 'imported_srfm_id' => $this->find_existing_srfm_id( $source_id ), + ]; + } + return $out; + } + + /** + * Import (or dry-run) the selected source forms into SureForms. + * + * @since x.x.x + * + * @param array $selected_ids List of source form ids to import. Empty = all. + * @param bool $dry_run If true, no posts are inserted and a preview is returned. + * @return array{imported: array>, failed: array, unsupported_fields: array, preview?: array} + */ + public function import_forms( array $selected_ids = [], $dry_run = false ) { + $this->unsupported_fields = []; + $result = [ + 'imported' => [], + 'failed' => [], + 'unsupported_fields' => [], + ]; + + if ( ! $this->exist() ) { + return $result; + } + + $preview = []; + + 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; + } + + $content = $this->append_submit_button( $content ); + $markup = Block_Templates::form_wrapper( $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 ); + if ( $existing_id ) { + $post_id = $this->update_form_post( $existing_id, $form, $markup, $metas ); + } else { + $post_id = $this->insert_form_post( $form, $markup, $metas ); + } + + 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; + } + + /** + * Insert a new sureforms_form post. + * + * @since x.x.x + * + * @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). + * @return int Inserted post id, or 0 on failure. + */ + protected function insert_form_post( array $form, $markup, array $metas = [] ) { + $args = [ + 'post_type' => SRFM_FORMS_POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $this->get_source_form_name( $form ), + 'post_content' => $markup, + ]; + if ( ! empty( $metas ) ) { + $args['meta_input'] = $metas; + } + $post_id = wp_insert_post( $args, true ); + return is_wp_error( $post_id ) ? 0 : (int) $post_id; + } + + /** + * Update an existing sureforms_form post with re-imported markup. + * + * @since x.x.x + * + * @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 ), + 'post_content' => $markup, + ]; + if ( ! empty( $metas ) ) { + $args['meta_input'] = $metas; + } + $updated = wp_update_post( $args, true ); + return is_wp_error( $updated ) ? 0 : (int) $updated; + } + + /** + * Append a default submit button to the concatenated field markup. + * + * Subclasses may override to use a source-derived submit label. + * + * @since x.x.x + * + * @param string $content Concatenated field markup. + * @return string + */ + protected function append_submit_button( $content ) { + return $content . Block_Templates::submit_button( [ 'text' => __( 'Submit', 'sureforms' ) ] ); + } + + /** + * Find the existing SureForms post id (if any) that was imported from a + * given source identifier. + * + * @since x.x.x + * + * @param int|string $source_id Source form id. + * @return int 0 if not previously imported. + */ + protected function find_existing_srfm_id( $source_id ) { + $map = get_option( self::IMPORTED_MAP_OPTION, [] ); + if ( ! is_array( $map ) ) { + return 0; + } + foreach ( $map as $srfm_id => $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + if ( (string) ( $entry['source_id'] ?? '' ) !== (string) $source_id ) { + continue; + } + if ( ( $entry['source_key'] ?? '' ) !== $this->key ) { + continue; + } + // Confirm the SureForms post still exists; otherwise prune stale entry. + $post = get_post( (int) $srfm_id ); + if ( $post && SRFM_FORMS_POST_TYPE === $post->post_type ) { + return (int) $srfm_id; + } + unset( $map[ $srfm_id ] ); + update_option( self::IMPORTED_MAP_OPTION, $map, false ); + return 0; + } + return 0; + } + + /** + * Record an import mapping for future idempotency checks. + * + * @since x.x.x + * + * @param int $srfm_id Newly inserted/updated SureForms post id. + * @param int|string $source_id Source-plugin form id. + * @return void + */ + protected function record_import_mapping( $srfm_id, $source_id ) { + $map = get_option( self::IMPORTED_MAP_OPTION, [] ); + if ( ! is_array( $map ) ) { + $map = []; + } + $map[ (int) $srfm_id ] = [ + 'source_id' => $source_id, + 'source_key' => $this->key, + 'last_imported' => current_time( 'mysql' ), + ]; + update_option( self::IMPORTED_MAP_OPTION, $map, false ); + } + + /** + * Push a label onto the unsupported-fields list. + * + * @since x.x.x + * + * @param string $label Field label. + * @return void + */ + protected function note_unsupported( $label ) { + $label = trim( (string) $label ); + $this->unsupported_fields[] = '' === $label ? __( '(unnamed field)', 'sureforms' ) : $label; + } + + /** + * Source key accessor. + * + * @since x.x.x + * @return string + */ + public function get_key() { + return $this->key; + } + + /** + * Display title accessor. + * + * @since x.x.x + * @return string + */ + public function get_title() { + return $this->title; + } +} diff --git a/inc/migrator/block-templates.php b/inc/migrator/block-templates.php new file mode 100644 index 000000000..8d3bda410 --- /dev/null +++ b/inc/migrator/block-templates.php @@ -0,0 +1,496 @@ +/block.json` — only attributes + * different from the block's defaults are emitted, to keep imported forms + * indistinguishable from hand-built ones in the editor. + * + * @package sureforms + * @since x.x.x + */ + +namespace SRFM\Inc\Migrator; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Block_Templates + * + * @since x.x.x + */ +class Block_Templates { + + /** + * Build a srfm/input block (text / email-as-text / password / date / hidden). + * + * @since x.x.x + * + * @param array $args Field args (label, placeholder, required, fieldType, etc.). + * @return string Serialized Gutenberg block markup. + */ + public static function input( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Text Field' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'defaultValue' => self::str( $args, 'default_value' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'textLength' => self::int_or_null( $args, 'max_length' ), + 'inputMask' => self::str( $args, 'input_mask', 'none' ), + 'slug' => self::slugify( self::str( $args, 'label', 'text-field' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/input', $attrs ); + } + + /** + * Build a srfm/email block. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function email( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Email' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'defaultValue' => self::str( $args, 'default_value' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'slug' => self::slugify( self::str( $args, 'label', 'email' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/email', $attrs ); + } + + /** + * Build a srfm/url block. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function url( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'URL' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'defaultValue' => self::str( $args, 'default_value' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'slug' => self::slugify( self::str( $args, 'label', 'url' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/url', $attrs ); + } + + /** + * Build a srfm/phone block. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function phone( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Phone Number' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'slug' => self::slugify( self::str( $args, 'label', 'phone' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/phone', $attrs ); + } + + /** + * Build a srfm/number block. Handles range/slider via min/max args. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function number( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Number' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'defaultValue' => self::str( $args, 'default_value' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'minValue' => self::int_or_null( $args, 'min' ), + 'maxValue' => self::int_or_null( $args, 'max' ), + 'prefix' => self::str( $args, 'prefix' ), + 'suffix' => self::str( $args, 'suffix' ), + 'slug' => self::slugify( self::str( $args, 'label', 'number' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/number', $attrs ); + } + + /** + * Build a srfm/textarea block. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function textarea( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Textarea' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'defaultValue' => self::str( $args, 'default_value' ), + 'required' => self::bool( $args, 'required' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'minLength' => self::int_or_null( $args, 'min_length' ), + 'maxLength' => self::int_or_null( $args, 'max_length' ), + 'rows' => self::int_or_null( $args, 'rows', 4 ), + 'slug' => self::slugify( self::str( $args, 'label', 'textarea' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/textarea', $attrs ); + } + + /** + * Build a srfm/dropdown (single-select or multi-select) block. + * + * @since x.x.x + * + * @param array $args Field args. Expects `options` => list of strings. + * @return string + */ + public static function dropdown( array $args ) { + $options = self::format_options( $args, 'label' ); + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Dropdown' ), + 'placeholder' => self::str( $args, 'placeholder' ), + 'required' => self::bool( $args, 'required' ), + 'multiSelect' => self::bool( $args, 'multiple' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'options' => $options, + 'preselectedOptions' => self::array_or_null( $args, 'preselected' ), + 'slug' => self::slugify( self::str( $args, 'label', 'dropdown' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/dropdown', $attrs ); + } + + /** + * Build a srfm/multi-choice (radio / multiple-choice) block. + * + * @since x.x.x + * + * @param array $args Field args. Expects `options` => list of strings. + * @return string + */ + public static function multi_choice( array $args ) { + $options = self::format_options( $args, 'optionTitle' ); + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Multi Choice' ), + 'required' => self::bool( $args, 'required' ), + 'singleSelection' => ! self::bool( $args, 'multiple' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'options' => $options, + 'preselectedOptions' => self::array_or_null( $args, 'preselected' ), + 'slug' => self::slugify( self::str( $args, 'label', 'multi-choice' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/multi-choice', $attrs ); + } + + /** + * Build a srfm/checkbox block. + * + * @since x.x.x + * + * @param array $args Field args. + * @return string + */ + public static function checkbox( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'Checkbox' ), + 'required' => self::bool( $args, 'required' ), + 'checked' => self::bool( $args, 'checked' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'slug' => self::slugify( self::str( $args, 'label', 'checkbox' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/checkbox', $attrs ); + } + + /** + * Build a srfm/gdpr block. + * + * @since x.x.x + * + * @param array $args Field args. Expects `label` (consent text). + * @return string + */ + public static function gdpr( array $args ) { + $attrs = self::strip_empty( + [ + 'label' => self::str( $args, 'label', 'I consent to have this website store my submitted information so they can respond to my inquiry.' ), + 'checked' => self::bool( $args, 'checked' ), + 'help' => self::str( $args, 'help' ), + 'errorMsg' => self::str( $args, 'error_message' ), + 'slug' => self::slugify( self::str( $args, 'label', 'consent' ) ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/gdpr', $attrs ); + } + + /** + * Build a srfm/inline-button submit button. + * + * @since x.x.x + * + * @param array $args Args. Expects `text` (button label). + * @return string + */ + public static function submit_button( array $args = [] ) { + $attrs = self::strip_empty( + [ + 'buttonText' => self::str( $args, 'text', 'Submit' ), + 'block_id' => self::block_id(), + ] + ); + return self::serialize_block( 'srfm/inline-button', $attrs ); + } + + /** + * Wrap a concatenated string of field-block markup in a srfm/form block. + * + * @since x.x.x + * + * @param string $inner Concatenated inner block markup. + * @return string + */ + public static function form_wrapper( $inner ) { + $attrs = self::strip_empty( + [ + 'blockId' => self::block_id(), + ] + ); + $json = self::encode_attrs( $attrs ); + return "\n{$inner}"; + } + + /** + * Generate a deterministic Gutenberg-style block id. + * + * @since x.x.x + * + * @return string An 8-character lowercase hex slug. + */ + public static function block_id() { + return substr( md5( wp_generate_uuid4() ), 0, 8 ); + } + + /** + * Serialize a self-closing Gutenberg block with the given name and attributes. + * + * @since x.x.x + * + * @param string $name Block name (e.g. 'srfm/input'). + * @param array $attrs Block attributes. + * @return string Block markup. + */ + private static function serialize_block( $name, array $attrs ) { + $json = self::encode_attrs( $attrs ); + return "\n"; + } + + /** + * JSON-encode block attributes for Gutenberg comment delimiter. + * + * @since x.x.x + * + * @param array $attrs Attributes to encode. + * @return string JSON string, or '{}' if empty. + */ + private static function encode_attrs( array $attrs ) { + if ( empty( $attrs ) ) { + return '{}'; + } + $json = wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + return is_string( $json ) ? $json : '{}'; + } + + /** + * Drop keys whose value is null, empty string, or empty array. + * + * Keeps zero-valued numbers and `false` booleans, which are meaningful. + * + * @since x.x.x + * + * @param array $arr Attribute array. + * @return array + */ + private static function strip_empty( array $arr ) { + return array_filter( + $arr, + static function ( $v ) { + if ( null === $v ) { + return false; + } + if ( '' === $v ) { + return false; + } + if ( is_array( $v ) && empty( $v ) ) { + return false; + } + return true; + } + ); + } + + /** + * Fetch a string from args with a default. + * + * @since x.x.x + * + * @param array $args Args. + * @param string $key Key to fetch. + * @param string $default Default if missing/empty. + * @return string + */ + private static function str( array $args, $key, $default = '' ) { + if ( ! isset( $args[ $key ] ) ) { + return $default; + } + $v = $args[ $key ]; + if ( ! is_string( $v ) && ! is_numeric( $v ) ) { + return $default; + } + return (string) $v; + } + + /** + * Fetch a boolean from args. + * + * @since x.x.x + * + * @param array $args Args. + * @param string $key Key. + * @return bool + */ + private static function bool( array $args, $key ) { + return ! empty( $args[ $key ] ); + } + + /** + * Fetch an integer or return null if missing. + * + * @since x.x.x + * + * @param array $args Args. + * @param string $key Key. + * @param int|null $default Default if missing. + * @return int|null + */ + private static function int_or_null( array $args, $key, $default = null ) { + if ( ! isset( $args[ $key ] ) || '' === $args[ $key ] ) { + return $default; + } + if ( ! is_numeric( $args[ $key ] ) ) { + return $default; + } + return (int) $args[ $key ]; + } + + /** + * Fetch an array or return null if missing. + * + * @since x.x.x + * + * @param array $args Args. + * @param string $key Key. + * @return array|null + */ + private static function array_or_null( array $args, $key ) { + if ( ! isset( $args[ $key ] ) || ! is_array( $args[ $key ] ) || empty( $args[ $key ] ) ) { + return null; + } + return array_values( $args[ $key ] ); + } + + /** + * Convert a label string to a URL-safe slug, lowercased. + * + * @since x.x.x + * + * @param string $label Source label. + * @return string Slug — falls back to 'field' if empty. + */ + private static function slugify( $label ) { + $slug = sanitize_title( $label ); + return $slug ? $slug : 'field'; + } + + /** + * Format a list of source-plugin choices into SureForms dropdown/multi-choice + * option shape. + * + * @since x.x.x + * + * @param array $args Field args containing 'options'. + * @param string $label_key Either 'label' (dropdown) or 'optionTitle' (multi-choice). + * @return array> + */ + private static function format_options( array $args, $label_key ) { + if ( empty( $args['options'] ) || ! is_array( $args['options'] ) ) { + return [ + [ $label_key => 'Option 1' ], + ]; + } + $out = []; + foreach ( $args['options'] as $opt ) { + if ( is_string( $opt ) ) { + $out[] = [ $label_key => $opt ]; + continue; + } + if ( is_array( $opt ) && isset( $opt['label'] ) ) { + $out[] = [ $label_key => (string) $opt['label'] ]; + } + } + return empty( $out ) ? [ [ $label_key => 'Option 1' ] ] : $out; + } +} diff --git a/inc/migrator/bootstrap.php b/inc/migrator/bootstrap.php new file mode 100644 index 000000000..8d215a010 --- /dev/null +++ b/inc/migrator/bootstrap.php @@ -0,0 +1,266 @@ +[a-z0-9]+)/forms — list forms in one source + * POST /migrator/sources/(?P[a-z0-9]+)/import — import selected forms + * POST /migrator/sources/(?P[a-z0-9]+)/forms/(?P[a-zA-Z0-9_-]+)/entries — entries + * + * Each route uses `Helper::get_items_permissions_check` for capability gating, + * matching the existing pattern in `inc/rest-api.php`. + * + * @package sureforms + * @since x.x.x + */ + +namespace SRFM\Inc\Migrator; + +use SRFM\Inc\Helper; +use SRFM\Inc\Migrator\Importers\Cf7_Importer; +use SRFM\Inc\Traits\Get_Instance; +use WP_REST_Request; +use WP_REST_Response; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Bootstrap + * + * @since x.x.x + */ +class Bootstrap { + use Get_Instance; + + /** + * Allowlist of source keys → importer classes. + * + * @var array + */ + private $importer_classes = [ + 'cf7' => Cf7_Importer::class, + ]; + + /** + * Constructor — hook into the REST endpoint filter. + * + * @since x.x.x + */ + public function __construct() { + add_filter( 'srfm_rest_api_endpoints', [ $this, 'register_routes' ] ); + } + + /** + * Append migrator routes to the SureForms REST endpoint registry. + * + * @since x.x.x + * + * @param array> $endpoints Existing endpoint registry. + * @return array> + */ + public function register_routes( $endpoints ) { + if ( ! is_array( $endpoints ) ) { + $endpoints = []; + } + + $endpoints['migrator/sources'] = [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_list_sources' ], + 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ], + ]; + + $endpoints['migrator/sources/(?P[a-z0-9]+)/forms'] = [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_list_forms' ], + 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ], + 'args' => [ + 'key' => [ + 'sanitize_callback' => 'sanitize_key', + ], + ], + ]; + + $endpoints['migrator/sources/(?P[a-z0-9]+)/import'] = [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_import_forms' ], + 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ], + 'args' => [ + 'key' => [ + 'sanitize_callback' => 'sanitize_key', + ], + 'form_ids' => [ + 'sanitize_callback' => [ $this, 'sanitize_form_ids' ], + 'default' => [], + ], + 'dry_run' => [ + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => false, + ], + ], + ]; + + return $endpoints; + } + + /** + * GET /migrator/sources — list importable plugins. + * + * @since x.x.x + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function rest_list_sources( $request ) { + $this->verify_nonce( $request ); + $out = []; + foreach ( array_keys( $this->importer_classes ) as $key ) { + $importer = $this->get_importer( $key ); + if ( null === $importer ) { + continue; + } + $forms_count = $importer->exist() ? count( $importer->list_forms() ) : 0; + $out[] = [ + 'key' => $importer->get_key(), + 'title' => $importer->get_title(), + 'installed' => $importer->exist(), + 'form_count' => $forms_count, + ]; + } + return new WP_REST_Response( [ 'sources' => $out ], 200 ); + } + + /** + * GET /migrator/sources/{key}/forms — list forms inside one source. + * + * @since x.x.x + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function rest_list_forms( $request ) { + $this->verify_nonce( $request ); + $key = (string) $request->get_param( 'key' ); + $importer = $this->get_importer( $key ); + if ( null === $importer ) { + return new WP_REST_Response( + [ 'message' => __( 'Unknown migration source.', 'sureforms' ) ], + 404 + ); + } + if ( ! $importer->exist() ) { + return new WP_REST_Response( + [ 'message' => __( 'Source plugin is not active.', 'sureforms' ) ], + 400 + ); + } + return new WP_REST_Response( + [ 'forms' => $importer->list_forms() ], + 200 + ); + } + + /** + * POST /migrator/sources/{key}/import — import (or dry-run) selected forms. + * + * @since x.x.x + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function rest_import_forms( $request ) { + $this->verify_nonce( $request ); + $key = (string) $request->get_param( 'key' ); + $importer = $this->get_importer( $key ); + if ( null === $importer ) { + return new WP_REST_Response( + [ 'message' => __( 'Unknown migration source.', 'sureforms' ) ], + 404 + ); + } + if ( ! $importer->exist() ) { + return new WP_REST_Response( + [ 'message' => __( 'Source plugin is not active.', 'sureforms' ) ], + 400 + ); + } + $form_ids = $request->get_param( 'form_ids' ); + if ( ! is_array( $form_ids ) ) { + $form_ids = []; + } + $dry_run = (bool) $request->get_param( 'dry_run' ); + $result = $importer->import_forms( $form_ids, $dry_run ); + return new WP_REST_Response( $result, 200 ); + } + + /** + * Resolve a source key into an importer instance. + * + * @since x.x.x + * + * @param string $key Source key. + * @return Base_Migrator|null + */ + private function get_importer( $key ) { + $key = sanitize_key( (string) $key ); + if ( ! isset( $this->importer_classes[ $key ] ) ) { + return null; + } + $class = $this->importer_classes[ $key ]; + if ( ! class_exists( $class ) ) { + return null; + } + $instance = new $class(); + return $instance instanceof Base_Migrator ? $instance : null; + } + + /** + * Verify the WordPress REST cookie nonce. + * + * Aborts the request via `wp_send_json_error` if the nonce is invalid, + * matching the pattern used in `inc/rest-api.php`. + * + * @since x.x.x + * + * @param WP_REST_Request $request REST request. + * @return void + */ + private function verify_nonce( $request ) { + $nonce = (string) $request->get_header( 'X-WP-Nonce' ); + if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) { + wp_send_json_error( + __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) + ); + } + } + + /** + * Sanitize a list of form ids — accepts ints or alphanumeric strings (source + * plugins use both). + * + * @since x.x.x + * + * @param mixed $value Raw value. + * @return array + */ + public function sanitize_form_ids( $value ) { + if ( ! is_array( $value ) ) { + return []; + } + $out = []; + foreach ( $value as $v ) { + if ( is_int( $v ) || is_numeric( $v ) ) { + $out[] = (string) (int) $v; + continue; + } + if ( is_string( $v ) ) { + $out[] = sanitize_text_field( $v ); + } + } + return $out; + } +} diff --git a/inc/migrator/importers/cf7-importer.php b/inc/migrator/importers/cf7-importer.php new file mode 100644 index 000000000..de1d7639f --- /dev/null +++ b/inc/migrator/importers/cf7-importer.php @@ -0,0 +1,540 @@ +key = 'cf7'; + $this->title = 'Contact Form 7'; + } + + /** + * Whether Contact Form 7 is currently active. + * + * @since x.x.x + * @return bool + */ + public function exist() { + return defined( 'WPCF7_PLUGIN' ) || defined( 'WPCF7_VERSION' ); + } + + /** + * Map a CF7 form-tag name (without the trailing `*`) to a SureForms block + * template method on Block_Templates. + * + * @since x.x.x + * + * @return array + */ + private function tag_to_template_map() { + return [ + 'text' => 'input', + 'email' => 'email', + 'url' => 'url', + 'tel' => 'phone', + 'number' => 'number', + 'range' => 'number', + 'date' => 'input', + 'textarea' => 'textarea', + 'select' => 'dropdown', + 'checkbox' => 'checkbox', + 'radio' => 'multi_choice', + 'acceptance' => 'gdpr', + ]; + } + + /** + * Enumerate all CF7 forms as `wpcf7_contact_form` posts. + * + * @since x.x.x + * @return array> + */ + protected function get_source_forms() { + $posts = get_posts( + [ + 'post_type' => 'wpcf7_contact_form', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ] + ); + $out = []; + foreach ( $posts as $post ) { + $out[] = [ + 'id' => (int) $post->ID, + 'name' => $post->post_title, + ]; + } + return $out; + } + + /** + * Get the source form id. + * + * @since x.x.x + * + * @param array $form Form descriptor from get_source_forms(). + * @return int + */ + protected function get_source_form_id( array $form ) { + if ( ! isset( $form['id'] ) || ! is_numeric( $form['id'] ) ) { + return 0; + } + return (int) $form['id']; + } + + /** + * Get the source form display name. + * + * @since x.x.x + * + * @param array $form Form descriptor. + * @return string + */ + protected function get_source_form_name( array $form ) { + $name = isset( $form['name'] ) && is_string( $form['name'] ) ? $form['name'] : ''; + return '' === $name ? __( 'Untitled Form', 'sureforms' ) : $name; + } + + /** + * Build SureForms post-meta payload for an imported CF7 form. + * + * CF7 stores its mail templates in `_mail` / `_mail_2` post meta using + * `[field-name]` shortcodes that don't map cleanly to SureForms smart + * tags. Rather than ship a half-working translation, we match Fluent + * Forms' approach: return a sane default admin notification and a + * generic confirmation message. The imported form is fully usable — + * users can refine the email body in SureForms' Single Form Settings. + * + * @since x.x.x + * + * @param array $form Form descriptor. + * @return array SureForms meta_input payload. + */ + protected function get_form_metas( array $form ) { + $title = $this->get_source_form_name( $form ); + + $notification = [ + 'id' => 1, + 'status' => true, + 'is_raw_format' => false, + 'name' => __( 'Admin Notification Email', 'sureforms' ), + 'email_to' => '{admin_email}', + 'email_reply_to' => '{admin_email}', + 'from_name' => '{site_title}', + 'from_email' => '{admin_email}', + 'email_cc' => '', + 'email_bcc' => '', + 'subject' => sprintf( + /* translators: %s: form title from the source CF7 form. */ + __( 'New submission: %s', 'sureforms' ), + $title + ), + 'email_body' => '{all_data}', + ]; + + $confirmation = [ + 'id' => 1, + 'confirmation_type' => 'same page', + 'page_url' => '', + 'custom_url' => '', + 'message' => $this->default_confirmation_message(), + 'submission_action' => 'hide form', + ]; + + return [ + '_srfm_email_notification' => [ $notification ], + '_srfm_form_confirmation' => [ $confirmation ], + ]; + } + + /** + * Default success message used for imported CF7 forms. Mirrors the + * canonical SureForms confirmation HTML registered in + * `inc/post-types.php` (centered heading + paragraph). + * + * @since x.x.x + * + * @return string Confirmation HTML. + */ + private function default_confirmation_message() { + $heading = esc_html__( 'Thank you', 'sureforms' ); + $body = esc_html__( + "Your form has been submitted successfully. We'll review your details and get back to you soon.", + 'sureforms' + ); + return sprintf( + '

%1$s

%2$s

', + $heading, + $body + ); + } + + /** + * Parse a CF7 form's shortcode template into SureForms block markup. + * + * Pipeline: + * 1. Read `_form` post meta. + * 2. Split into per-line tokens. + * 3. Strip `