cf7-importer.php
2 months ago
gravity-importer.php
2 months ago
ninja-importer.php
2 months ago
wpforms-importer.php
2 months ago
wpforms-importer.php
1056 lines
| 1 | <?php |
| 2 | /** |
| 3 | * WPForms importer — translates the JSON form schema stored in the |
| 4 | * `wpforms` CPT's `post_content` into SureForms block markup. |
| 5 | * |
| 6 | * Storage shape: a wp-slashed JSON blob (see WPForms' `wpforms_encode()`). |
| 7 | * Each field is keyed by integer id and carries `type`, `label`, `required`, |
| 8 | * plus type-specific keys. We iterate the field list and dispatch to a |
| 9 | * `Block_Templates::*` emitter — extending the same four filter seams the |
| 10 | * CF7 importer added in PR #2789 so SureForms Pro can plug in extra |
| 11 | * mappings for Pro-only fields (date-time, signature, repeater, …). |
| 12 | * |
| 13 | * @package sureforms |
| 14 | * @since 2.11.0 |
| 15 | */ |
| 16 | |
| 17 | namespace SRFM\Inc\Migrator\Importers; |
| 18 | |
| 19 | use SRFM\Inc\Migrator\Base_Migrator; |
| 20 | use SRFM\Inc\Migrator\Block_Templates; |
| 21 | |
| 22 | if ( ! defined( 'ABSPATH' ) ) { |
| 23 | exit; |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * Wpforms_Importer |
| 28 | * |
| 29 | * @since 2.11.0 |
| 30 | */ |
| 31 | class Wpforms_Importer extends Base_Migrator { |
| 32 | /** |
| 33 | * Operator translation table — WPForms condition operator → SureForms |
| 34 | * operator slug (per Pro's `conditional-logic-options.json`). Rules using |
| 35 | * operators absent from this map are dropped during migration with a |
| 36 | * one-line warning. |
| 37 | */ |
| 38 | private const OPERATOR_MAP = [ |
| 39 | '==' => '==', |
| 40 | '!=' => '!=', |
| 41 | 'c' => 'includes', |
| 42 | '!c' => '!includes', |
| 43 | '^' => 'startWith', |
| 44 | '~' => 'endWith', |
| 45 | 'e' => 'null', |
| 46 | '!e' => '!null', |
| 47 | ]; |
| 48 | |
| 49 | /** |
| 50 | * Maximum layout/repeater nesting depth translated before bailing. Real |
| 51 | * WPForms layouts can't nest, so this only guards crafted/corrupt JSON. |
| 52 | */ |
| 53 | private const MAX_NESTING_DEPTH = 5; |
| 54 | |
| 55 | /** |
| 56 | * Conditional-logic payload accumulated while emitting field blocks for |
| 57 | * the form currently being built. Reset per form in `build_form_content`. |
| 58 | * Each entry holds the source-side target field id, the show/hide |
| 59 | * action, the unconverted rules, and a choice-id map. |
| 60 | * |
| 61 | * @var array<int,array<string,mixed>> |
| 62 | */ |
| 63 | private $conditional_logic = []; |
| 64 | |
| 65 | /** |
| 66 | * Mapping from WPForms field id → assembled SureForms block_id for the |
| 67 | * current form. Used to rewrite conditional-logic rule targets (which |
| 68 | * reference WPForms ids) onto SureForms block ids in `get_form_metas`. |
| 69 | * |
| 70 | * @var array<string,string> |
| 71 | */ |
| 72 | private $field_id_to_block_id = []; |
| 73 | |
| 74 | /** |
| 75 | * Mapping from WPForms field id → block type bucket (`default`, `text`, |
| 76 | * `number`, `list`) — required by SureForms Pro's CL schema so its rule |
| 77 | * editor knows which operator set to expose. |
| 78 | * |
| 79 | * @var array<string,string> |
| 80 | */ |
| 81 | private $field_id_to_block_type = []; |
| 82 | |
| 83 | /** |
| 84 | * Mapping from WPForms choice-field id → [ choice key => SureForms option |
| 85 | * label ]. WPForms stores a conditional rule's value as the choice KEY, but |
| 86 | * SureForms' CL engine compares against the option label — so convert_rule() |
| 87 | * re-keys list-source rule values through this map. |
| 88 | * |
| 89 | * @var array<string,array<string,string>> |
| 90 | */ |
| 91 | private $field_id_to_choices = []; |
| 92 | |
| 93 | /** |
| 94 | * Submit-button text discovered while parsing form settings; threaded |
| 95 | * through to `get_form_metas` as `_srfm_submit_button_text`. |
| 96 | * |
| 97 | * @var string |
| 98 | */ |
| 99 | private $submit_label = ''; |
| 100 | |
| 101 | /** |
| 102 | * Confirmation message + recipient discovered while parsing settings. |
| 103 | * |
| 104 | * @var array<string,mixed> |
| 105 | */ |
| 106 | private $form_settings = []; |
| 107 | |
| 108 | /** |
| 109 | * Set source identifiers. |
| 110 | * |
| 111 | * @since 2.11.0 |
| 112 | */ |
| 113 | public function __construct() { |
| 114 | $this->key = 'wpforms'; |
| 115 | $this->title = __( 'WPForms', 'sureforms' ); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Whether WPForms (Lite or Pro) is currently installed/active. |
| 120 | * |
| 121 | * @since 2.11.0 |
| 122 | * |
| 123 | * @return bool |
| 124 | */ |
| 125 | public function exist() { |
| 126 | return class_exists( 'WPForms' ) || defined( 'WPFORMS_VERSION' ); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Map of WPForms field-type slug → `Block_Templates` method name for the |
| 131 | * Lite-only fields. Pro and addon fields are added by Pro's |
| 132 | * `Migrator_WPForms` subscriber via the `srfm_migrator_tag_to_template_map` |
| 133 | * filter — the same filter the CF7 importer uses. |
| 134 | * |
| 135 | * Public so tests can read it; Pro subscribers extend it through the |
| 136 | * filter, not through inheritance. |
| 137 | * |
| 138 | * @since 2.11.0 |
| 139 | * |
| 140 | * @return array<string,string> |
| 141 | */ |
| 142 | public function default_field_map() { |
| 143 | return [ |
| 144 | 'text' => 'input', |
| 145 | 'textarea' => 'textarea', |
| 146 | 'email' => 'email', |
| 147 | 'number' => 'number', |
| 148 | 'number-slider' => 'slider', |
| 149 | 'select' => 'dropdown', |
| 150 | 'radio' => 'multi_choice', |
| 151 | 'checkbox' => 'multi_choice', |
| 152 | 'gdpr-checkbox' => 'gdpr', |
| 153 | ]; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Fetch all `wpforms` posts. The CPT is `public=false, show_ui=false` so we |
| 158 | * have to query it explicitly. |
| 159 | * |
| 160 | * @since 2.11.0 |
| 161 | * |
| 162 | * @return array<int,array<string,mixed>> |
| 163 | */ |
| 164 | protected function get_source_forms() { |
| 165 | if ( ! $this->exist() ) { |
| 166 | return []; |
| 167 | } |
| 168 | $posts = get_posts( |
| 169 | [ |
| 170 | 'post_type' => 'wpforms', |
| 171 | 'post_status' => 'publish', |
| 172 | 'posts_per_page' => -1, |
| 173 | 'orderby' => 'ID', |
| 174 | 'order' => 'ASC', |
| 175 | ] |
| 176 | ); |
| 177 | $out = []; |
| 178 | foreach ( $posts as $post ) { |
| 179 | $out[] = [ |
| 180 | 'id' => (int) $post->ID, |
| 181 | 'name' => $post->post_title, |
| 182 | 'post_content' => $post->post_content, |
| 183 | ]; |
| 184 | } |
| 185 | return $out; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Return the WP post id for a source descriptor. |
| 190 | * |
| 191 | * @since 2.11.0 |
| 192 | * |
| 193 | * @param array<string,mixed> $form Source descriptor. |
| 194 | * @return int |
| 195 | */ |
| 196 | protected function get_source_form_id( array $form ) { |
| 197 | return isset( $form['id'] ) && is_numeric( $form['id'] ) ? (int) $form['id'] : 0; |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Return the WPForms form title for a source descriptor. |
| 202 | * |
| 203 | * @since 2.11.0 |
| 204 | * |
| 205 | * @param array<string,mixed> $form Source descriptor. |
| 206 | * @return string |
| 207 | */ |
| 208 | protected function get_source_form_name( array $form ) { |
| 209 | $name = $this->str_arg( $form, 'name' ); |
| 210 | return '' !== $name ? $name : __( '(untitled WPForms form)', 'sureforms' ); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Decode the JSON form schema and iterate fields, emitting SureForms |
| 215 | * block markup. Sub-fields of composite types (Name, Layout, Repeater) |
| 216 | * are handled inline rather than via the field map. |
| 217 | * |
| 218 | * @since 2.11.0 |
| 219 | * |
| 220 | * @param array<string,mixed> $form Source descriptor. |
| 221 | * @return string |
| 222 | */ |
| 223 | protected function build_form_content( array $form ) { |
| 224 | // Reset per-form accumulators — used_slugs lives on Base_Migrator. |
| 225 | $this->used_slugs = []; |
| 226 | $this->conditional_logic = []; |
| 227 | $this->field_id_to_block_id = []; |
| 228 | $this->field_id_to_block_type = []; |
| 229 | $this->field_id_to_choices = []; |
| 230 | $this->submit_label = ''; |
| 231 | $this->form_settings = []; |
| 232 | |
| 233 | $data = $this->parse_form_json( $form ); |
| 234 | if ( empty( $data ) ) { |
| 235 | return ''; |
| 236 | } |
| 237 | |
| 238 | // Allow add-on subscribers to rewrite the form-data array before we |
| 239 | // iterate it (e.g. expand a Multi-Step addon section into synthetic |
| 240 | // page-break markers, much like CF7 importer's [step] preprocessing). |
| 241 | /** |
| 242 | * Filter the parsed WPForms form-data array before block emission. |
| 243 | * |
| 244 | * @since 2.11.0 |
| 245 | * |
| 246 | * @param array<string,mixed> $data Decoded form_data. |
| 247 | * @param string $key Migrator source key (`wpforms`). |
| 248 | * @param array<string,mixed> $form Source descriptor. |
| 249 | */ |
| 250 | $data = (array) apply_filters( 'srfm_migrator_preprocess_template', $data, $this->key, $form ); |
| 251 | |
| 252 | // Stash form settings so get_form_metas can read them — WPForms keeps |
| 253 | // notifications/confirmations/submit_text under `settings`. |
| 254 | if ( isset( $data['settings'] ) && is_array( $data['settings'] ) ) { |
| 255 | $this->form_settings = $data['settings']; |
| 256 | $this->submit_label = $this->str_arg( $data['settings'], 'submit_text' ); |
| 257 | } |
| 258 | |
| 259 | $fields = isset( $data['fields'] ) && is_array( $data['fields'] ) ? $data['fields'] : []; |
| 260 | // WPForms keys fields by integer id but the JSON is associative — |
| 261 | // preserve source order by iterating with foreach (PHP keeps insertion |
| 262 | // order for assoc arrays). |
| 263 | $markup = ''; |
| 264 | foreach ( $fields as $field ) { |
| 265 | if ( ! is_array( $field ) ) { |
| 266 | continue; |
| 267 | } |
| 268 | $markup .= $this->translate_field( $field ); |
| 269 | } |
| 270 | return $markup; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Build the SureForms meta payload — email notifications, confirmation, |
| 275 | * submit text, and the conditional-logic blob assembled during emission. |
| 276 | * |
| 277 | * @since 2.11.0 |
| 278 | * |
| 279 | * @param array<string,mixed> $form Source descriptor. |
| 280 | * @return array<string,mixed> |
| 281 | */ |
| 282 | protected function get_form_metas( array $form ) { |
| 283 | unset( $form ); |
| 284 | |
| 285 | $metas = [ |
| 286 | '_srfm_submit_button_text' => '' !== $this->submit_label ? $this->submit_label : __( 'Submit', 'sureforms' ), |
| 287 | ]; |
| 288 | |
| 289 | $confirmation = $this->translate_confirmation( $this->form_settings ); |
| 290 | if ( ! empty( $confirmation ) ) { |
| 291 | $metas['_srfm_form_confirmation'] = $confirmation; |
| 292 | } |
| 293 | |
| 294 | $email = $this->translate_email_notifications( $this->form_settings ); |
| 295 | if ( ! empty( $email ) ) { |
| 296 | $metas['_srfm_email_notification'] = $email; |
| 297 | } |
| 298 | |
| 299 | // `_srfm_conditional_logic` is registered by SureForms Pro |
| 300 | // (inc/extensions/conditional-logic.php). When Pro is inactive the meta |
| 301 | // is still written but simply sits unused until Pro is enabled — harmless. |
| 302 | $cl_meta = $this->assemble_conditional_logic_meta(); |
| 303 | if ( ! empty( $cl_meta ) ) { |
| 304 | $metas['_srfm_conditional_logic'] = $cl_meta; |
| 305 | } |
| 306 | |
| 307 | return $metas; |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Decode the JSON form schema from `post_content`. Mirrors WPForms' own |
| 312 | * `wpforms_decode()` which calls `json_decode()` directly — `post_content` |
| 313 | * arrives from `get_post_field()` already unslashed, so a second pass |
| 314 | * would corrupt the escaped quotes inside string values (e.g. |
| 315 | * `replyto:"{field_id=\"1\"}"`). |
| 316 | * |
| 317 | * @since 2.11.0 |
| 318 | * |
| 319 | * @param array<string,mixed> $form Source descriptor. |
| 320 | * @return array<string,mixed> |
| 321 | */ |
| 322 | private function parse_form_json( array $form ) { |
| 323 | $raw = $this->str_arg( $form, 'post_content' ); |
| 324 | if ( '' === $raw ) { |
| 325 | return []; |
| 326 | } |
| 327 | $decoded = json_decode( $raw, true ); |
| 328 | return is_array( $decoded ) ? $decoded : []; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Translate a single WPForms field array into block markup. |
| 333 | * |
| 334 | * Branches: |
| 335 | * - `name` — composite, emits 1–3 `srfm/input` blocks based on `format`. |
| 336 | * - `layout` — emits `core/columns` and recurses children. |
| 337 | * - everything else — looked up in the field map (extended via filter), |
| 338 | * dispatched via `Base_Migrator::dispatch_template()`, with the |
| 339 | * `srfm_migrator_block_template` filter giving Pro subscribers a |
| 340 | * chance for unmapped types before we flag them unsupported. |
| 341 | * |
| 342 | * @since 2.11.0 |
| 343 | * |
| 344 | * @param array<string,mixed> $field WPForms field array. |
| 345 | * @param int $depth Current nesting depth (layout/repeater recursion). |
| 346 | * @return string |
| 347 | */ |
| 348 | private function translate_field( array $field, $depth = 0 ) { |
| 349 | // Guard against a crafted/corrupt self-nesting layout/repeater chain that |
| 350 | // would otherwise recurse unbounded during an authenticated import. |
| 351 | if ( $depth > self::MAX_NESTING_DEPTH ) { |
| 352 | $this->note_unsupported( $this->str_arg( $field, 'label', $this->str_arg( $field, 'type', 'nested field' ) ) ); |
| 353 | return ''; |
| 354 | } |
| 355 | |
| 356 | $type = $this->str_arg( $field, 'type' ); |
| 357 | if ( '' === $type ) { |
| 358 | return ''; |
| 359 | } |
| 360 | |
| 361 | if ( 'name' === $type ) { |
| 362 | return $this->translate_name_field( $field ); |
| 363 | } |
| 364 | |
| 365 | if ( 'layout' === $type ) { |
| 366 | return $this->translate_layout_field( $field, $depth ); |
| 367 | } |
| 368 | |
| 369 | if ( 'repeater' === $type ) { |
| 370 | return $this->translate_repeater_field( $field, $depth ); |
| 371 | } |
| 372 | |
| 373 | // captcha is rendered form-level in SureForms, never as a block. |
| 374 | if ( 0 === strpos( $type, 'captcha_' ) || 'captcha' === $type ) { |
| 375 | return ''; |
| 376 | } |
| 377 | |
| 378 | /** |
| 379 | * Filter the WPForms-field-type → template-method map. |
| 380 | * |
| 381 | * Pro subscribers (Migrator_WPForms) overlay extra entries for fields |
| 382 | * Free can't render on its own (date-time, file-upload, signature, |
| 383 | * repeater, …). The filter is the same one CF7 uses — discriminate by |
| 384 | * the second parameter `$key`. |
| 385 | * |
| 386 | * @since 2.11.0 |
| 387 | * |
| 388 | * @param array<string,string> $map WPForms field type → method name. |
| 389 | * @param string $key Migrator source key (`wpforms`). |
| 390 | */ |
| 391 | $map = (array) apply_filters( 'srfm_migrator_tag_to_template_map', $this->default_field_map(), $this->key ); |
| 392 | |
| 393 | // Pre-build the SureForms block args so the dispatch_template + filter |
| 394 | // path both receive the same shape (label, required, choices, …). |
| 395 | $args = $this->build_block_args( $field ); |
| 396 | $method = $map[ $type ] ?? ''; |
| 397 | |
| 398 | if ( '' === $method ) { |
| 399 | // No mapping for this type — give subscribers a chance, otherwise |
| 400 | // flag it as unsupported. |
| 401 | $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $type, $args, $this->key ); |
| 402 | if ( '' === $markup ) { |
| 403 | $this->note_unsupported( $this->str_arg( $field, 'label', $type ) ); |
| 404 | return ''; |
| 405 | } |
| 406 | return $this->capture_field_metadata( $field, $args, $markup, $type ); |
| 407 | } |
| 408 | |
| 409 | $markup = $this->dispatch_template( $method, $args ); |
| 410 | if ( '' === $markup ) { |
| 411 | // Subscriber-only emitter (e.g. Pro `date_picker`). |
| 412 | $markup = (string) apply_filters( 'srfm_migrator_block_template', '', $method, $args, $this->key ); |
| 413 | } |
| 414 | if ( '' === $markup ) { |
| 415 | $this->note_unsupported( $this->str_arg( $field, 'label', $type ) ); |
| 416 | return ''; |
| 417 | } |
| 418 | |
| 419 | return $this->capture_field_metadata( $field, $args, $markup, $method ); |
| 420 | } |
| 421 | |
| 422 | /** |
| 423 | * Build SureForms block args from a WPForms field array. Common keys are |
| 424 | * mapped 1:1; type-specific keys (choices, min/max, format, …) are added |
| 425 | * conditionally. |
| 426 | * |
| 427 | * @since 2.11.0 |
| 428 | * |
| 429 | * @param array<string,mixed> $field WPForms field. |
| 430 | * @return array<string,mixed> |
| 431 | */ |
| 432 | private function build_block_args( array $field ) { |
| 433 | $type = $this->str_arg( $field, 'type' ); |
| 434 | $label = $this->str_arg( $field, 'label' ); |
| 435 | $slug_seed = '' !== $label ? $label : $type; |
| 436 | $slug = $this->reserve_slug( $slug_seed ); |
| 437 | |
| 438 | $args = [ |
| 439 | 'label' => $label, |
| 440 | 'placeholder' => $this->str_arg( $field, 'placeholder' ), |
| 441 | 'default_value' => $this->str_arg( $field, 'default_value' ), |
| 442 | 'required' => ! empty( $field['required'] ), |
| 443 | 'help' => $this->str_arg( $field, 'description' ), |
| 444 | 'slug' => $slug, |
| 445 | ]; |
| 446 | |
| 447 | switch ( $type ) { |
| 448 | case 'textarea': |
| 449 | $args['rows'] = $this->size_to_rows( $this->str_arg( $field, 'size' ) ); |
| 450 | $args['max_length'] = $this->resolve_limit( $field ); |
| 451 | break; |
| 452 | case 'text': |
| 453 | $args['max_length'] = $this->resolve_limit( $field ); |
| 454 | break; |
| 455 | case 'number': |
| 456 | case 'number-slider': |
| 457 | // Preserve decimals — a WPForms slider step of 0.5 must not be |
| 458 | // truncated to 0. `+ 0` yields an int for whole numbers, float otherwise. |
| 459 | if ( isset( $field['min'] ) && is_numeric( $field['min'] ) ) { |
| 460 | $args['min'] = $field['min'] + 0; |
| 461 | } |
| 462 | if ( isset( $field['max'] ) && is_numeric( $field['max'] ) ) { |
| 463 | $args['max'] = $field['max'] + 0; |
| 464 | } |
| 465 | if ( isset( $field['step'] ) && is_numeric( $field['step'] ) ) { |
| 466 | $args['step'] = $field['step'] + 0; |
| 467 | } |
| 468 | if ( '' !== $this->str_arg( $field, 'default_value' ) ) { |
| 469 | $args['default_value'] = $this->str_arg( $field, 'default_value' ); |
| 470 | } |
| 471 | break; |
| 472 | case 'select': |
| 473 | case 'radio': |
| 474 | case 'checkbox': |
| 475 | $options = $this->translate_choices( $field ); |
| 476 | $args['options'] = $options['options']; |
| 477 | $args['preselected'] = $options['preselected']; |
| 478 | $args['_choice_id_map'] = $options['id_map']; // Internal — capture_field_metadata reads + strips it. |
| 479 | $args['multiple'] = 'checkbox' === $type |
| 480 | ? empty( $field['disclaimer_format'] ) // Disclaimer = single-checkbox semantics. |
| 481 | : ! empty( $field['multiple'] ); |
| 482 | break; |
| 483 | case 'email': |
| 484 | // WPForms' "Enable Email Confirmation" (confirmation=1) is a |
| 485 | // second input on the same field; srfm/email models that |
| 486 | // natively via isConfirmEmail, so enable the option on the one |
| 487 | // block instead of dropping it or emitting a duplicate field. |
| 488 | if ( ! empty( $field['confirmation'] ) ) { |
| 489 | $args['confirm_email'] = true; |
| 490 | } |
| 491 | break; |
| 492 | case 'date-time': |
| 493 | $args['format'] = $this->str_arg( $field, 'format', 'date' ); |
| 494 | $args['date_format'] = $this->str_arg( $field, 'date_format', 'mm/dd/yyyy' ); |
| 495 | $args['time_format'] = $this->str_arg( $field, 'time_format' ); |
| 496 | break; |
| 497 | case 'rating': |
| 498 | $args['icon'] = $this->str_arg( $field, 'icon', 'star' ); |
| 499 | $args['scale'] = isset( $field['scale'] ) && is_numeric( $field['scale'] ) ? (int) $field['scale'] : 5; |
| 500 | break; |
| 501 | case 'net_promoter_score': |
| 502 | $args['low_label'] = $this->str_arg( $field, 'nps_low_label' ); |
| 503 | $args['high_label'] = $this->str_arg( $field, 'nps_high_label' ); |
| 504 | break; |
| 505 | case 'signature': |
| 506 | $args['ink_color'] = $this->str_arg( $field, 'ink_color' ); |
| 507 | break; |
| 508 | case 'html': |
| 509 | $args['content'] = $this->str_arg( $field, 'code' ); |
| 510 | break; |
| 511 | case 'content': |
| 512 | $args['content'] = $this->str_arg( $field, 'content' ); |
| 513 | break; |
| 514 | case 'divider': |
| 515 | // label + help are already in $args; nothing extra to carry. |
| 516 | break; |
| 517 | case 'file-upload': |
| 518 | $exts = $this->str_arg( $field, 'extensions' ); |
| 519 | if ( '' !== $exts ) { |
| 520 | $args['allowed_formats'] = array_values( |
| 521 | array_filter( |
| 522 | array_map( 'trim', explode( ',', $exts ) ), |
| 523 | static function ( $v ) { |
| 524 | return '' !== $v; |
| 525 | } |
| 526 | ) |
| 527 | ); |
| 528 | } |
| 529 | if ( isset( $field['max_size'] ) && is_numeric( $field['max_size'] ) ) { |
| 530 | $args['file_size_limit'] = (int) $field['max_size']; |
| 531 | } |
| 532 | if ( isset( $field['max_file_number'] ) && is_numeric( $field['max_file_number'] ) ) { |
| 533 | $args['max_files'] = (int) $field['max_file_number']; |
| 534 | } |
| 535 | $args['multiple'] = isset( $field['max_file_number'] ) && is_numeric( $field['max_file_number'] ) && (int) $field['max_file_number'] > 1; |
| 536 | break; |
| 537 | case 'hidden': |
| 538 | case 'internal-information': |
| 539 | $args['default_value'] = $this->str_arg( $field, 'default_value' ); |
| 540 | if ( '' === $args['default_value'] ) { |
| 541 | $args['default_value'] = $this->str_arg( $field, 'code' ); |
| 542 | } |
| 543 | break; |
| 544 | case 'phone': |
| 545 | // WPForms phone format `smart` / `us` / `international` — SureForms |
| 546 | // phone has its own intl/US toggle; we carry the raw value through |
| 547 | // and let the Pro emitter (or default) interpret. |
| 548 | $args['format'] = $this->str_arg( $field, 'format', 'smart' ); |
| 549 | break; |
| 550 | case 'address': |
| 551 | $args['format'] = $this->str_arg( $field, 'format', 'us' ); |
| 552 | break; |
| 553 | case 'pagebreak': |
| 554 | // Title precedes the page-break block heading; map it onto label. |
| 555 | $title = $this->str_arg( $field, 'title' ); |
| 556 | if ( '' !== $title ) { |
| 557 | $args['label'] = $title; |
| 558 | } |
| 559 | break; |
| 560 | } |
| 561 | |
| 562 | return $args; |
| 563 | } |
| 564 | |
| 565 | /** |
| 566 | * Translate WPForms' Name composite (`format=simple|first-last|first-middle-last`) |
| 567 | * into 1–3 `srfm/input` blocks. SureForms has no dedicated Name block. |
| 568 | * |
| 569 | * @since 2.11.0 |
| 570 | * |
| 571 | * @param array<string,mixed> $field Name field. |
| 572 | * @return string |
| 573 | */ |
| 574 | private function translate_name_field( array $field ) { |
| 575 | $format = $this->str_arg( $field, 'format', 'first-last' ); |
| 576 | $base = $this->str_arg( $field, 'label', 'Name' ); |
| 577 | $req = ! empty( $field['required'] ); |
| 578 | |
| 579 | if ( 'simple' === $format ) { |
| 580 | $markup = Block_Templates::input( |
| 581 | [ |
| 582 | 'label' => $base, |
| 583 | 'placeholder' => $this->str_arg( $field, 'simple_placeholder' ), |
| 584 | 'default_value' => $this->str_arg( $field, 'simple_default' ), |
| 585 | 'required' => $req, |
| 586 | 'slug' => $this->reserve_slug( $base ), |
| 587 | ] |
| 588 | ); |
| 589 | // Register field id → first sub-block id so a Name field can be a |
| 590 | // conditional-logic source/target (capture_field_metadata reads the |
| 591 | // first block_id from the markup and any rules off the field). |
| 592 | return $this->capture_field_metadata( $field, [], $markup, 'name' ); |
| 593 | } |
| 594 | |
| 595 | $parts = 'first-middle-last' === $format |
| 596 | ? [ 'first', 'middle', 'last' ] |
| 597 | : [ 'first', 'last' ]; |
| 598 | |
| 599 | $markup = ''; |
| 600 | foreach ( $parts as $part ) { |
| 601 | $markup .= Block_Templates::input( |
| 602 | [ |
| 603 | 'label' => $base . ' (' . ucfirst( $part ) . ')', |
| 604 | 'placeholder' => $this->str_arg( $field, $part . '_placeholder' ), |
| 605 | 'default_value' => $this->str_arg( $field, $part . '_default' ), |
| 606 | 'required' => $req, |
| 607 | 'slug' => $this->reserve_slug( $base . '-' . $part ), |
| 608 | ] |
| 609 | ); |
| 610 | } |
| 611 | // Map the composite field to its first sub-block id so CL rules that |
| 612 | // target the Name field resolve to the first input. |
| 613 | return $this->capture_field_metadata( $field, [], $markup, 'name' ); |
| 614 | } |
| 615 | |
| 616 | /** |
| 617 | * Translate WPForms' Layout container — emit a `core/columns` block with |
| 618 | * width presets from the WPForms `preset` (50-50, 33-33-33, …) and |
| 619 | * recurse the nested children into each column. |
| 620 | * |
| 621 | * @since 2.11.0 |
| 622 | * |
| 623 | * @param array<string,mixed> $field Layout field. |
| 624 | * @param int $depth Current nesting depth. |
| 625 | * @return string |
| 626 | */ |
| 627 | private function translate_layout_field( array $field, $depth = 0 ) { |
| 628 | $columns = isset( $field['columns'] ) && is_array( $field['columns'] ) ? $field['columns'] : []; |
| 629 | if ( empty( $columns ) ) { |
| 630 | return ''; |
| 631 | } |
| 632 | $out = "<!-- wp:columns -->\n<div class=\"wp-block-columns\">"; |
| 633 | foreach ( $columns as $column ) { |
| 634 | if ( ! is_array( $column ) ) { |
| 635 | continue; |
| 636 | } |
| 637 | $children = isset( $column['fields'] ) && is_array( $column['fields'] ) ? $column['fields'] : []; |
| 638 | $inner = ''; |
| 639 | foreach ( $children as $child ) { |
| 640 | if ( is_array( $child ) ) { |
| 641 | $inner .= $this->translate_field( $child, $depth + 1 ); |
| 642 | } |
| 643 | } |
| 644 | $out .= "\n<!-- wp:column -->\n<div class=\"wp-block-column\">{$inner}</div>\n<!-- /wp:column -->"; |
| 645 | } |
| 646 | $out .= "\n</div>\n<!-- /wp:columns -->\n"; |
| 647 | return $out; |
| 648 | } |
| 649 | |
| 650 | /** |
| 651 | * Translate WPForms' Repeater container — recurse into each column's |
| 652 | * child fields, assemble their markup, then hand it to the Pro |
| 653 | * `repeater_container` emitter as `children` so it can wrap with |
| 654 | * Gutenberg innerBlocks markers. |
| 655 | * |
| 656 | * When Pro is not active, the dispatch falls through to the |
| 657 | * `srfm_migrator_block_template` filter and (since no subscriber |
| 658 | * answers) the field is flagged as unsupported — the children are |
| 659 | * still tracked so the warning is meaningful. |
| 660 | * |
| 661 | * @since 2.11.0 |
| 662 | * |
| 663 | * @param array<string,mixed> $field WPForms repeater field. |
| 664 | * @param int $depth Current nesting depth. |
| 665 | * @return string |
| 666 | */ |
| 667 | private function translate_repeater_field( array $field, $depth = 0 ) { |
| 668 | $columns = isset( $field['columns'] ) && is_array( $field['columns'] ) ? $field['columns'] : []; |
| 669 | $children = ''; |
| 670 | foreach ( $columns as $column ) { |
| 671 | if ( ! is_array( $column ) ) { |
| 672 | continue; |
| 673 | } |
| 674 | $child_fields = isset( $column['fields'] ) && is_array( $column['fields'] ) ? $column['fields'] : []; |
| 675 | foreach ( $child_fields as $child ) { |
| 676 | if ( is_array( $child ) ) { |
| 677 | $children .= $this->translate_field( $child, $depth + 1 ); |
| 678 | } |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | $args = [ |
| 683 | 'label' => $this->str_arg( $field, 'label', 'Repeater Field' ), |
| 684 | 'help' => $this->str_arg( $field, 'description' ), |
| 685 | 'min_rows' => isset( $field['min_rows'] ) && is_numeric( $field['min_rows'] ) ? (int) $field['min_rows'] : null, |
| 686 | 'max_rows' => isset( $field['max_rows'] ) && is_numeric( $field['max_rows'] ) ? (int) $field['max_rows'] : null, |
| 687 | 'slug' => $this->reserve_slug( $this->str_arg( $field, 'label', 'repeater' ) ), |
| 688 | 'children' => $children, |
| 689 | ]; |
| 690 | |
| 691 | $markup = (string) apply_filters( 'srfm_migrator_block_template', '', 'repeater_container', $args, $this->key ); |
| 692 | if ( '' === $markup ) { |
| 693 | $this->note_unsupported( $this->str_arg( $field, 'label', 'Repeater' ) ); |
| 694 | return $children; // Fall back to inlining the children at top level so data isn't lost. |
| 695 | } |
| 696 | return $markup; |
| 697 | } |
| 698 | |
| 699 | /** |
| 700 | * Translate WPForms' 1-indexed choices array into SureForms options + |
| 701 | * `preselected` defaults + an id-map (WPForms choice id → SureForms |
| 702 | * option index). The id-map is consumed by `translate_conditional_logic` |
| 703 | * to rewrite rule values. |
| 704 | * |
| 705 | * @since 2.11.0 |
| 706 | * |
| 707 | * @param array<string,mixed> $field WPForms field. |
| 708 | * @return array{options: array<int,array<string,string>>, preselected: array<int,int>, id_map: array<string,string>} |
| 709 | */ |
| 710 | private function translate_choices( array $field ) { |
| 711 | $choices = isset( $field['choices'] ) && is_array( $field['choices'] ) ? $field['choices'] : []; |
| 712 | $show_values = ! empty( $field['show_values'] ); |
| 713 | $options = []; |
| 714 | $preselected = []; |
| 715 | $id_map = []; |
| 716 | $i = 0; |
| 717 | |
| 718 | foreach ( $choices as $cid => $choice ) { |
| 719 | if ( ! is_array( $choice ) ) { |
| 720 | continue; |
| 721 | } |
| 722 | $label = $this->str_arg( $choice, 'label' ); |
| 723 | $value = $show_values && ! empty( $choice['value'] ) ? (string) $choice['value'] : $label; |
| 724 | $entry = [ 'label' => $value ]; |
| 725 | $options[] = $entry; |
| 726 | // Map the WPForms choice KEY to the SureForms option label. WPForms |
| 727 | // conditional rules reference a choice by its key; SureForms' CL |
| 728 | // engine compares against the option label (= $value here), so this |
| 729 | // lets convert_rule() rewrite the rule value to one that matches. |
| 730 | $id_map[ (string) $cid ] = $value; |
| 731 | // SureForms' dropdown / multi-choice render preselected entries by |
| 732 | // matching the option *index*, not the label — see |
| 733 | // inc/fields/dropdown-markup.php:162. Push the integer index here. |
| 734 | if ( ! empty( $choice['default'] ) ) { |
| 735 | $preselected[] = $i; |
| 736 | } |
| 737 | ++$i; |
| 738 | } |
| 739 | |
| 740 | if ( empty( $options ) ) { |
| 741 | $options = [ [ 'label' => 'Option 1' ] ]; |
| 742 | } |
| 743 | |
| 744 | return [ |
| 745 | 'options' => $options, |
| 746 | 'preselected' => $preselected, |
| 747 | 'id_map' => $id_map, |
| 748 | ]; |
| 749 | } |
| 750 | |
| 751 | /** |
| 752 | * Resolve the `limit_count` for a text/textarea field — only the |
| 753 | * `characters` mode maps cleanly; `words` mode is noted as a partial |
| 754 | * loss and converted to a generous character heuristic (count × 7). |
| 755 | * |
| 756 | * @since 2.11.0 |
| 757 | * |
| 758 | * @param array<string,mixed> $field WPForms field. |
| 759 | * @return int|null |
| 760 | */ |
| 761 | private function resolve_limit( array $field ) { |
| 762 | if ( empty( $field['limit_enabled'] ) ) { |
| 763 | return null; |
| 764 | } |
| 765 | $count = isset( $field['limit_count'] ) && is_numeric( $field['limit_count'] ) ? (int) $field['limit_count'] : 0; |
| 766 | if ( $count <= 0 ) { |
| 767 | return null; |
| 768 | } |
| 769 | $mode = $this->str_arg( $field, 'limit_mode', 'characters' ); |
| 770 | if ( 'words' === $mode ) { |
| 771 | $this->note_unsupported( $this->str_arg( $field, 'label' ) . ' (' . __( 'word limit converted to character heuristic', 'sureforms' ) . ')' ); |
| 772 | return $count * 7; |
| 773 | } |
| 774 | return $count; |
| 775 | } |
| 776 | |
| 777 | /** |
| 778 | * Map WPForms field `size` → SureForms textarea row count. |
| 779 | * |
| 780 | * @since 2.11.0 |
| 781 | * |
| 782 | * @param string $size 'small', 'medium', 'large'. |
| 783 | * @return int |
| 784 | */ |
| 785 | private function size_to_rows( $size ) { |
| 786 | switch ( $size ) { |
| 787 | case 'small': |
| 788 | return 3; |
| 789 | case 'large': |
| 790 | return 8; |
| 791 | case 'medium': |
| 792 | default: |
| 793 | return 5; |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | /** |
| 798 | * After a field's block markup is built, extract its block_id from the |
| 799 | * markup and stash the WPForms-id → block_id mapping. Also stages the |
| 800 | * field's conditional logic for the get_form_metas payload. |
| 801 | * |
| 802 | * @since 2.11.0 |
| 803 | * |
| 804 | * @param array<string,mixed> $field Source field. |
| 805 | * @param array<string,mixed> $args Final block args (used to resolve choice id-map). |
| 806 | * @param string $markup Assembled block markup. |
| 807 | * @param string $type_key WPForms type or method name (used as block-type bucket). |
| 808 | * @return string Markup (unchanged). |
| 809 | */ |
| 810 | private function capture_field_metadata( array $field, array $args, $markup, $type_key ) { |
| 811 | $field_id = $this->str_arg( $field, 'id' ); |
| 812 | if ( '' !== $field_id && preg_match( '/"block_id":"([a-f0-9]{8})"/', $markup, $m ) ) { |
| 813 | $this->field_id_to_block_id[ $field_id ] = $m[1]; |
| 814 | $this->field_id_to_block_type[ $field_id ] = $this->block_type_bucket( $type_key ); |
| 815 | // Remember a choice field's [ key => option label ] map so a rule that |
| 816 | // uses this field as a CL source can have its value re-keyed. |
| 817 | if ( ! empty( $args['_choice_id_map'] ) && is_array( $args['_choice_id_map'] ) ) { |
| 818 | $this->field_id_to_choices[ $field_id ] = $args['_choice_id_map']; |
| 819 | } |
| 820 | } |
| 821 | |
| 822 | if ( ! empty( $field['conditional_logic'] ) && ! empty( $field['conditionals'] ) && is_array( $field['conditionals'] ) ) { |
| 823 | $this->conditional_logic[] = [ |
| 824 | 'target_field_id' => $field_id, |
| 825 | 'action' => $this->str_arg( $field, 'conditional_type', 'show' ), |
| 826 | 'rules' => $field['conditionals'], |
| 827 | ]; |
| 828 | } |
| 829 | |
| 830 | return $markup; |
| 831 | } |
| 832 | |
| 833 | /** |
| 834 | * Coarse bucket for SureForms' CL editor — `default` for text-like fields, |
| 835 | * `number` for numeric, `list` for choice fields. |
| 836 | * |
| 837 | * @since 2.11.0 |
| 838 | * |
| 839 | * @param string $type WPForms type or template-method name. |
| 840 | * @return string |
| 841 | */ |
| 842 | private function block_type_bucket( $type ) { |
| 843 | if ( in_array( $type, [ 'number', 'number-slider', 'slider' ], true ) ) { |
| 844 | return 'number'; |
| 845 | } |
| 846 | if ( in_array( $type, [ 'select', 'radio', 'checkbox', 'multi_choice', 'dropdown' ], true ) ) { |
| 847 | return 'list'; |
| 848 | } |
| 849 | return 'default'; |
| 850 | } |
| 851 | |
| 852 | /** |
| 853 | * Build the `_srfm_conditional_logic` post-meta payload from the |
| 854 | * accumulated rules. Source-side field ids are rewritten to |
| 855 | * SureForms block ids; unsupported operators are dropped with a |
| 856 | * single warning per rule. |
| 857 | * |
| 858 | * @since 2.11.0 |
| 859 | * |
| 860 | * @return array<int,array<string,mixed>> |
| 861 | */ |
| 862 | private function assemble_conditional_logic_meta() { |
| 863 | $out = []; |
| 864 | foreach ( $this->conditional_logic as $entry ) { |
| 865 | $target_field_id = $this->str_arg( $entry, 'target_field_id' ); |
| 866 | $target_block_id = $this->field_id_to_block_id[ $target_field_id ] ?? ''; |
| 867 | if ( '' === $target_block_id ) { |
| 868 | continue; |
| 869 | } |
| 870 | $rules = isset( $entry['rules'] ) && is_array( $entry['rules'] ) ? $entry['rules'] : []; |
| 871 | $logic = []; |
| 872 | foreach ( $rules as $group ) { |
| 873 | if ( ! is_array( $group ) ) { |
| 874 | continue; |
| 875 | } |
| 876 | $converted_group = []; |
| 877 | foreach ( $group as $rule ) { |
| 878 | if ( ! is_array( $rule ) ) { |
| 879 | continue; |
| 880 | } |
| 881 | $converted = $this->convert_rule( $rule ); |
| 882 | if ( null !== $converted ) { |
| 883 | $converted_group[] = $converted; |
| 884 | } |
| 885 | } |
| 886 | if ( ! empty( $converted_group ) ) { |
| 887 | $logic[] = $converted_group; |
| 888 | } |
| 889 | } |
| 890 | if ( empty( $logic ) ) { |
| 891 | continue; |
| 892 | } |
| 893 | $action = $this->str_arg( $entry, 'action', 'show' ); |
| 894 | $out[] = [ |
| 895 | $target_block_id => [ |
| 896 | 'action' => '' !== $action ? $action : 'show', |
| 897 | 'logic' => $logic, |
| 898 | ], |
| 899 | ]; |
| 900 | } |
| 901 | return $out; |
| 902 | } |
| 903 | |
| 904 | /** |
| 905 | * Convert one WPForms conditional-logic rule into the SureForms shape. |
| 906 | * |
| 907 | * @since 2.11.0 |
| 908 | * |
| 909 | * @param array<string,mixed> $rule WPForms rule (`{ field, operator, value }`). |
| 910 | * @return array<string,string>|null Null when the rule references an |
| 911 | * unknown source field or uses an |
| 912 | * unsupported operator. |
| 913 | */ |
| 914 | private function convert_rule( array $rule ) { |
| 915 | $src_field_id = $this->str_arg( $rule, 'field', '' ); |
| 916 | $source_block = $this->field_id_to_block_id[ $src_field_id ] ?? ''; |
| 917 | if ( '' === $source_block ) { |
| 918 | return null; |
| 919 | } |
| 920 | $op = $this->str_arg( $rule, 'operator', '==' ); |
| 921 | if ( ! isset( self::OPERATOR_MAP[ $op ] ) ) { |
| 922 | return null; |
| 923 | } |
| 924 | $operator = self::OPERATOR_MAP[ $op ]; |
| 925 | $bucket = $this->field_id_to_block_type[ $src_field_id ] ?? 'default'; |
| 926 | // Reconcile the operator with the field's bucket — SureForms' CL editor |
| 927 | // only evaluates a restricted operator set per bucket, so a list/number |
| 928 | // field with a text-style operator is down-bucketed to `default` (or the |
| 929 | // rule is dropped when no bucket supports it). |
| 930 | $bucket = $this->resolve_cl_bucket( $operator, $bucket ); |
| 931 | if ( '' === $bucket ) { |
| 932 | return null; |
| 933 | } |
| 934 | |
| 935 | // WPForms stores a choice source's rule value as the choice KEY (e.g. |
| 936 | // "2"); SureForms' CL engine compares against the option label, so |
| 937 | // re-key it through the source field's [ key => label ] map. Text/number |
| 938 | // sources have no choice map and pass through unchanged. |
| 939 | $value = $this->str_arg( $rule, 'value', '' ); |
| 940 | $choice_map = $this->field_id_to_choices[ $src_field_id ] ?? []; |
| 941 | if ( ! empty( $choice_map ) && array_key_exists( $value, $choice_map ) ) { |
| 942 | $value = (string) $choice_map[ $value ]; |
| 943 | } |
| 944 | |
| 945 | return [ |
| 946 | 'field' => $source_block, |
| 947 | 'operator' => $operator, |
| 948 | 'value' => $value, |
| 949 | 'type' => $bucket, |
| 950 | ]; |
| 951 | } |
| 952 | |
| 953 | /** |
| 954 | * Translate WPForms' first email notification (the only one SureForms |
| 955 | * supports) into the `_srfm_email_notification` shape. |
| 956 | * |
| 957 | * @since 2.11.0 |
| 958 | * |
| 959 | * @param array<string,mixed> $settings WPForms settings array. |
| 960 | * @return array<int,array<string,mixed>> |
| 961 | */ |
| 962 | private function translate_email_notifications( array $settings ) { |
| 963 | $notifications = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : []; |
| 964 | if ( empty( $notifications ) ) { |
| 965 | return []; |
| 966 | } |
| 967 | // SureForms supports a single notification; surface the dropped extras. |
| 968 | if ( count( $notifications ) > 1 ) { |
| 969 | $this->note_unsupported( __( 'Additional email notifications (only the first was imported)', 'sureforms' ) ); |
| 970 | } |
| 971 | $first = reset( $notifications ); |
| 972 | if ( ! is_array( $first ) ) { |
| 973 | return []; |
| 974 | } |
| 975 | $to_email = $this->str_arg( $first, 'email' ); |
| 976 | if ( '' === $to_email ) { |
| 977 | $admin = get_option( 'admin_email' ); |
| 978 | $to_email = is_string( $admin ) ? $admin : ''; |
| 979 | } |
| 980 | |
| 981 | return [ |
| 982 | [ |
| 983 | 'status' => true, |
| 984 | 'name' => $this->str_arg( $first, 'notification_name', __( 'Admin Notification', 'sureforms' ) ), |
| 985 | 'email_to' => $to_email, |
| 986 | 'subject' => $this->str_arg( $first, 'subject', __( 'New form submission', 'sureforms' ) ), |
| 987 | 'email_reply_to' => $this->str_arg( $first, 'replyto', '{admin_email}' ), |
| 988 | 'email_body' => $this->str_arg( $first, 'message', '{all_fields}' ), |
| 989 | ], |
| 990 | ]; |
| 991 | } |
| 992 | |
| 993 | /** |
| 994 | * Translate WPForms' first confirmation entry into the SureForms |
| 995 | * `_srfm_form_confirmation` shape. Falls back to the canonical default |
| 996 | * confirmation when WPForms didn't customize it. |
| 997 | * |
| 998 | * @since 2.11.0 |
| 999 | * |
| 1000 | * @param array<string,mixed> $settings WPForms settings array. |
| 1001 | * @return array<int,array<string,mixed>> |
| 1002 | */ |
| 1003 | private function translate_confirmation( array $settings ) { |
| 1004 | $confirmations = isset( $settings['confirmations'] ) && is_array( $settings['confirmations'] ) ? $settings['confirmations'] : []; |
| 1005 | // SureForms supports a single confirmation; surface the dropped extras. |
| 1006 | if ( count( $confirmations ) > 1 ) { |
| 1007 | $this->note_unsupported( __( 'Additional confirmations (only the first was imported)', 'sureforms' ) ); |
| 1008 | } |
| 1009 | $first = is_array( reset( $confirmations ) ) ? reset( $confirmations ) : []; |
| 1010 | $type = $this->str_arg( $first, 'type', 'message' ); |
| 1011 | |
| 1012 | $entry = [ |
| 1013 | 'confirmation_type' => 'same page', |
| 1014 | 'message' => $this->default_confirmation_message(), |
| 1015 | 'page_url' => '', |
| 1016 | ]; |
| 1017 | |
| 1018 | if ( 'message' === $type && ! empty( $first['message'] ) ) { |
| 1019 | $entry['message'] = wp_kses_post( (string) $first['message'] ); |
| 1020 | } |
| 1021 | if ( 'redirect' === $type && ! empty( $first['redirect'] ) ) { |
| 1022 | $entry['confirmation_type'] = 'different page'; |
| 1023 | $entry['page_url'] = esc_url_raw( $this->str_arg( $first, 'redirect' ) ); |
| 1024 | } |
| 1025 | |
| 1026 | return [ $entry ]; |
| 1027 | } |
| 1028 | |
| 1029 | /** |
| 1030 | * Coerce a mixed array entry to a string. PHPStan level 9 rejects a |
| 1031 | * direct `(string) $mixed` cast; this helper centralises the |
| 1032 | * `is_string` / `is_scalar` guards so the per-field translators above |
| 1033 | * stay readable. |
| 1034 | * |
| 1035 | * @since 2.11.0 |
| 1036 | * |
| 1037 | * @param array<string,mixed> $arr Source array. |
| 1038 | * @param string $key Key to read. |
| 1039 | * @param string $default Default when missing or non-scalar. |
| 1040 | * @return string |
| 1041 | */ |
| 1042 | private function str_arg( array $arr, $key, $default = '' ) { |
| 1043 | if ( ! isset( $arr[ $key ] ) ) { |
| 1044 | return $default; |
| 1045 | } |
| 1046 | $value = $arr[ $key ]; |
| 1047 | if ( is_string( $value ) ) { |
| 1048 | return $value; |
| 1049 | } |
| 1050 | if ( is_scalar( $value ) ) { |
| 1051 | return (string) $value; |
| 1052 | } |
| 1053 | return $default; |
| 1054 | } |
| 1055 | } |
| 1056 |