PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.3
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.3
4.0.3 4.0.2 4.0.1 4.0.0 3.16.6 3.16.5 3.16.4 3.16.3 3.16.2 3.16.1 3.16.0 3.15.9 3.9.9 3.9.5 3.9.6 3.9.7 3.9.8 1.1.7 1.1.8 1.1.9 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 341 releases
profile-builder / form-builder / form-builder-meta-names.php

form-builder-meta-names.php in User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor 4.0.3, at form-builder/form-builder-meta-names.php

792 lines 33.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Meta-name policy for form fields. Pure helpers, no hooks.
4 * `meta-name` is the wp_usermeta key; shared with legacy admin, front end, and Repeater.
5 * New rules go in wppb_fb_resolve_meta_name() so all save paths stay in sync.
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) exit;
9
10 /**
11 * Canonical meta-names for default field types (mirrors admin/manage-fields.php).
12 * Empty string: wp_update_user or no user data. Non-empty: legacy wp_usermeta key.
13 */
14 function wppb_fb_default_field_meta_names() {
15 static $cached = null;
16 if ( $cached !== null ) return $cached;
17 $cached = array(
18 'Default - Name (Heading)' => '',
19 'Default - Contact Info (Heading)' => '',
20 'Default - About Yourself (Heading)' => '',
21 'Default - Username' => '',
22 'Default - E-mail' => '',
23 'Default - Website' => '',
24 'Default - Display name publicly as' => '',
25 'Default - Password' => '',
26 'Default - Repeat Password' => '',
27 'Default - Blog Details' => '',
28 'Default - First Name' => 'first_name',
29 'Default - Last Name' => 'last_name',
30 'Default - Nickname' => 'nickname',
31 'Default - Biographical Info' => 'description',
32 'Default - AIM' => 'aim',
33 'Default - Yahoo IM' => 'yim',
34 'Default - Jabber / Google Talk' => 'jabber',
35 );
36 return $cached;
37 }
38
39 /** Field types with no storable meta-name. */
40 function wppb_fb_no_meta_name_field_types() {
41 static $cached = null;
42 if ( $cached !== null ) return $cached;
43 $cached = array_merge(
44 array_keys( array_filter(
45 wppb_fb_default_field_meta_names(),
46 function ( $v ) { return $v === ''; }
47 ) ),
48 array(
49 'Heading',
50 'HTML',
51 'reCAPTCHA',
52 'Turnstile',
53 'Honeypot',
54 'Select (User Role)',
55 'GDPR Delete Button',
56 'Email Confirmation',
57 'MailChimp Subscribe',
58 'MailPoet Subscribe',
59 'Campaign Monitor Subscribe',
60 'Subscription Plans',
61 'PMS Billing Fields',
62 )
63 );
64 return $cached;
65 }
66
67 /**
68 * Types that hide Overwrite-existing despite having a meta-name (classic editor rule).
69 * Not tied to meta-name editability — fixed-meta types like GDPR Checkbox still show it.
70 */
71 function wppb_fb_no_overwrite_existing_field_types() {
72 static $cached = null;
73 if ( $cached !== null ) return $cached;
74 $cached = array(
75 'Validation',
76 'WooCommerce Customer Billing Address',
77 'WooCommerce Customer Shipping Address',
78 );
79 return $cached;
80 }
81
82 /**
83 * Fixed meta-names for add-on field types. Downstream code reads these keys hardcoded;
84 * a drifted key drops stored data. Resolver ignores user input; editor shows read-only.
85 */
86 function wppb_fb_fixed_meta_name_field_types() {
87 static $cached = null;
88 if ( $cached !== null ) return $cached;
89 $cached = array(
90 'GDPR Checkbox' => 'user_consent_gdpr',
91 'GDPR Communication Preferences' => 'gdpr_communication_preferences',
92 'WooCommerce Customer Billing Address' => 'wppbwoo_billing',
93 'WooCommerce Customer Shipping Address' => 'wppbwoo_shipping',
94 );
95 return $cached;
96 }
97
98 /**
99 * wppb_manage_fields, always as an array.
100 * A string value fatals on foreach / []=; every read goes through here.
101 */
102 function wppb_fb_manage_fields() {
103 $manage = get_option( 'wppb_manage_fields', array() );
104
105 return is_array( $manage ) ? $manage : array();
106 }
107
108 /** Next free field id on a working copy (Repeater sub-fields share the global id space). */
109 function wppb_fb_next_field_id( $manage, $extra_ids = array() ) {
110 $ids = $extra_ids;
111 foreach ( $manage as $row ) {
112 if ( isset( $row['id'] ) ) $ids[] = (int) $row['id'];
113 }
114 if ( empty( $ids ) ) return 1;
115 return ( (int) max( $ids ) ) + 1;
116 }
117
118 /**
119 * One-pass Repeater sub-field snapshot: ids plus metanames_by_repeater.
120 * Snapshot-once — in-save option rewrites are not seen; use $current_repeater_meta to exclude.
121 */
122 function wppb_fb_collect_global_subfield_state( $manage ) {
123 $state = array(
124 'ids' => array(),
125 'metanames_by_repeater' => array(),
126 );
127 if ( ! is_array( $manage ) ) return $state;
128
129 foreach ( $manage as $row ) {
130 if ( ! isset( $row['field'], $row['meta-name'] ) ) continue;
131 if ( $row['field'] !== 'Repeater' || $row['meta-name'] === '' ) continue;
132
133 $r_meta = (string) $row['meta-name'];
134 $group = get_option( $r_meta, 'not_set' );
135 if ( $group === 'not_set' || ! is_array( $group ) ) continue;
136
137 $r_metanames = array();
138 foreach ( $group as $subfield ) {
139 if ( isset( $subfield['id'] ) ) $state['ids'][] = (int) $subfield['id'];
140 if ( ! empty( $subfield['meta-name'] ) ) $r_metanames[] = (string) $subfield['meta-name'];
141 }
142 $state['metanames_by_repeater'][ $r_meta ] = $r_metanames;
143 }
144 return $state;
145 }
146
147 /**
148 * Merge a Repeater group into the threaded snapshot so sibling Repeaters see new sub-fields.
149 * Replaces metanames_by_repeater for that Repeater; appends ids.
150 */
151 function wppb_fb_merge_repeater_group_into_state( $state, $repeater_meta_name, $group ) {
152 $names = array();
153 foreach ( (array) $group as $subfield ) {
154 if ( ! is_array( $subfield ) ) continue;
155 if ( isset( $subfield['id'] ) ) $state['ids'][] = (int) $subfield['id'];
156 if ( ! empty( $subfield['meta-name'] ) ) $names[] = (string) $subfield['meta-name'];
157 }
158 $state['metanames_by_repeater'][ (string) $repeater_meta_name ] = $names;
159
160 return $state;
161 }
162
163 /** All Repeater sub-field IDs. Works with the add-on unloaded; $state reuses a snapshot. */
164 function wppb_fb_collect_global_subfield_ids( $manage, $state = null ) {
165 if ( is_array( $state ) && isset( $state['ids'] ) ) {
166 return $state['ids'];
167 }
168 $ids = array();
169 if ( ! is_array( $manage ) ) return $ids;
170 foreach ( $manage as $row ) {
171 if ( ! isset( $row['field'], $row['meta-name'] ) ) continue;
172 if ( $row['field'] !== 'Repeater' || $row['meta-name'] === '' ) continue;
173 $group = get_option( $row['meta-name'], 'not_set' );
174 if ( $group === 'not_set' || ! is_array( $group ) ) continue;
175 foreach ( $group as $subfield ) {
176 if ( isset( $subfield['id'] ) ) $ids[] = (int) $subfield['id'];
177 }
178 }
179 return $ids;
180 }
181
182 /**
183 * All Repeater sub-field meta-names. $exclude_repeater_meta skips a Repeater being rewritten this save.
184 * $state reuses a snapshot.
185 */
186 function wppb_fb_collect_global_subfield_metanames( $manage, $exclude_repeater_meta = null, $state = null ) {
187 if ( is_array( $state ) && isset( $state['metanames_by_repeater'] ) ) {
188 $names = array();
189 foreach ( $state['metanames_by_repeater'] as $r_meta => $list ) {
190 if ( $exclude_repeater_meta !== null && $r_meta === $exclude_repeater_meta ) continue;
191 foreach ( $list as $n ) $names[] = $n;
192 }
193 return $names;
194 }
195 $names = array();
196 if ( ! is_array( $manage ) ) return $names;
197 foreach ( $manage as $row ) {
198 if ( ! isset( $row['field'], $row['meta-name'] ) ) continue;
199 if ( $row['field'] !== 'Repeater' || $row['meta-name'] === '' ) continue;
200 if ( $exclude_repeater_meta !== null && $row['meta-name'] === $exclude_repeater_meta ) continue;
201 $group = get_option( $row['meta-name'], 'not_set' );
202 if ( $group === 'not_set' || ! is_array( $group ) ) continue;
203 foreach ( $group as $subfield ) {
204 if ( ! empty( $subfield['meta-name'] ) ) $names[] = $subfield['meta-name'];
205 }
206 }
207 return $names;
208 }
209
210 /**
211 * Break Repeater runtime-key collisions on a candidate meta-name.
212 * Forward: M cannot match ^<sub>_[0-9]+$. Reverse (sub-fields): no name may match ^<M>_[0-9]+$.
213 * Exact match is skipped when $candidate === $previous_value (rename-tolerant). On hit, suffix _pb.
214 *
215 * @param string $previous_value Prior meta-name; empty on flatten (self-excludes via $current_repeater_meta).
216 */
217 function wppb_fb_break_runtime_collision( $candidate, $is_sub, $manage, $current_repeater_meta = null, $extra_known_metas = array(), $state = null, $previous_value = '' ) {
218 if ( ! is_string( $candidate ) || $candidate === '' ) return $candidate;
219
220 $sub_metas = wppb_fb_collect_global_subfield_metanames( $manage, $current_repeater_meta, $state );
221
222 // Siblings claimed this iteration but not yet in $manage/$state.
223 $extra = array();
224 if ( $is_sub ) {
225 foreach ( $extra_known_metas as $e ) {
226 if ( is_string( $e ) && $e !== '' && $e !== $candidate ) $extra[] = $e;
227 }
228 }
229
230 // Top-level meta-names for reverse and exact checks (sub-fields only).
231 $top_metas = array();
232 if ( $is_sub ) {
233 foreach ( $manage as $row ) {
234 if ( ! empty( $row['meta-name'] ) && is_string( $row['meta-name'] ) ) $top_metas[] = $row['meta-name'];
235 }
236 }
237
238 $guard = 0;
239 $collides = false;
240 while ( $guard++ < 64 ) {
241 $collides = false;
242
243 foreach ( array_merge( $sub_metas, $extra ) as $sub ) {
244 if ( $sub === '' ) continue;
245 if ( preg_match( '/^' . preg_quote( $sub, '/' ) . '_[0-9]+$/', $candidate ) ) {
246 $collides = true;
247 break;
248 }
249 }
250
251 if ( $is_sub && ! $collides ) {
252 $pattern = '/^' . preg_quote( $candidate, '/' ) . '_[0-9]+$/';
253 foreach ( array_merge( $sub_metas, $top_metas, $extra ) as $other ) {
254 if ( $other !== '' && preg_match( $pattern, $other ) ) {
255 $collides = true;
256 break;
257 }
258 }
259 }
260
261 // Exact match; skipped when unchanged. Top-level candidates skip $top_metas (own row + tree dedup).
262 if ( ! $collides && $candidate !== $previous_value ) {
263 $equality_pool = $is_sub ? array_merge( $sub_metas, $top_metas, $extra ) : $sub_metas;
264 foreach ( $equality_pool as $other ) {
265 if ( $other !== '' && $other === $candidate ) {
266 $collides = true;
267 break;
268 }
269 }
270 }
271
272 if ( ! $collides ) break;
273 $candidate .= '_pb';
274 }
275
276 // 64 suffixes still collide — never fail open; allocate a guaranteed-free name.
277 if ( $collides ) {
278 return wppb_fb_next_meta_name( $manage, 'custom_field_', array_merge( $sub_metas, $extra ) );
279 }
280
281 return $candidate;
282 }
283
284 /** Default Repeater meta-name when empty (legacy wppb_rpf_add_missing_meta_name shape). */
285 function wppb_fb_default_repeater_meta_name( $title ) {
286 $title = is_string( $title ) ? $title : '';
287 if ( class_exists( 'Wordpress_Creation_Kit_PB' ) && method_exists( 'Wordpress_Creation_Kit_PB', 'wck_generate_slug' ) ) {
288 $slug = Wordpress_Creation_Kit_PB::wck_generate_slug( $title );
289 } else {
290 $slug = strtolower( preg_replace( '/[^a-z0-9]+/i', '_', $title ) );
291 $slug = trim( $slug, '_' );
292 }
293 if ( $slug === '' ) $slug = 'group';
294 return 'wppb_repeater_field_' . $slug;
295 }
296
297 /**
298 * Next free custom_field_N on a working copy. $extra_names folds in Repeater sub-field names.
299 */
300 function wppb_fb_next_meta_name( $manage, $prefix = 'custom_field_', $extra_names = array() ) {
301 $numbers = array( 0 );
302 $rows = $manage;
303 foreach ( (array) $extra_names as $extra_name ) {
304 if ( is_string( $extra_name ) && $extra_name !== '' ) {
305 $rows[] = array( 'meta-name' => $extra_name );
306 }
307 }
308 foreach ( $rows as $row ) {
309 // is_string guard: array meta-name makes strpos() a PHP 8 TypeError.
310 if ( ! isset( $row['meta-name'] ) || ! is_string( $row['meta-name'] ) ) continue;
311 if ( strpos( $row['meta-name'], $prefix ) !== 0 ) continue;
312 // Tail after prefix only; custom_field_1_2 must not collapse to "12".
313 $tail = substr( $row['meta-name'], strlen( $prefix ) );
314 if ( ctype_digit( $tail ) ) $numbers[] = (int) $tail;
315 }
316 return $prefix . ( max( $numbers ) + 1 );
317 }
318
319 /**
320 * Sanitize meta-name at save time; never reject. Mirrors admin/manage-fields.php rules.
321 * $allow_duplicate skips tree-level uniqueness only (overwrite-existing carve-out).
322 */
323 function wppb_fb_sanitize_meta_name( $value, $field_type, $previous_value, $claimed, $manage, $allow_duplicate = false ) {
324 $value = is_string( $value ) ? trim( $value ) : '';
325
326 // Strip whitespace.
327 $value = preg_replace( '/\s+/', '', $value );
328
329 // Upload fields are stricter — lowercase only.
330 if ( $field_type === 'Upload' ) {
331 $value = strtolower( $value );
332 $value = preg_replace( '/[^a-z0-9_\-]/', '', $value );
333 }
334
335 // 255-char DB cap.
336 if ( strlen( $value ) > 255 ) {
337 $value = substr( $value, 0, 255 );
338 }
339
340 // Empty after sanitization — let the caller auto-generate.
341 if ( $value === '' ) return '';
342
343 // Rename-tolerant reserved-name check: only reject if this is a new name
344 // (not equal to the row's existing meta-name).
345 if ( $value !== $previous_value ) {
346 // Memo only for id=>0 (field-invariant); classic callers pass a real id.
347 static $reserved_cache = null;
348 if ( null === $reserved_cache ) {
349 $reserved_cache = function_exists( 'wppb_get_reserved_meta_name_list' )
350 ? wppb_get_reserved_meta_name_list( $manage, array( 'id' => 0, 'meta-name' => $value ) )
351 : array();
352 }
353 // Map keeps "map"; reserved lists are filterable like classic admin.
354 $skip_reserved_types = (array) apply_filters( 'wppb_skip_unique_meta_name_list_check', array( 'Map', 'Honeypot' ) );
355 if ( ! in_array( $field_type, $skip_reserved_types, true ) ) {
356 $strict_tokens = (array) apply_filters( 'wppb_unique_meta_name_list_strict', array( 'map' ) );
357 $hits_strict = false;
358 foreach ( $strict_tokens as $token ) {
359 if ( is_string( $token ) && $token !== '' && stripos( $value, $token ) !== false ) {
360 $hits_strict = true;
361 break;
362 }
363 }
364 if ( in_array( $value, $reserved_cache, true ) || $hits_strict ) {
365 // Prefix with `pb_` to make it safe rather than dropping the field.
366 $value = 'pb_' . $value;
367 }
368 }
369 }
370
371 // Sibling collision → next custom_field_N (not a numeric suffix). No
372 // value!==previous guard: an earlier sibling may already have claimed this name.
373 if ( ! $allow_duplicate && isset( $claimed[ $value ] ) ) {
374 $value = wppb_fb_next_meta_name( $manage );
375 // Defensive: a sibling may have claimed that custom_field_N earlier in
376 // this pass without its row being reflected in $manage yet. Bump past it.
377 while ( isset( $claimed[ $value ] ) ) {
378 $manage_plus = $manage;
379 $manage_plus[] = array( 'meta-name' => $value );
380 $value = wppb_fb_next_meta_name( $manage_plus );
381 }
382 }
383
384 return $value;
385 }
386
387 /**
388 * Add cross-cutting attributes (CL, meta-name, overwrite, add-on attrs).
389 * Sole implementation for registry + register_blocks — a missing attr is dropped/no-op.
390 *
391 * @param array $attributes Base attributes (typically from block.json).
392 * @param string $field_type Resolved PB field-type label (e.g. "Input").
393 * @param string $block_name Block name (e.g. "profile-builder/field-input").
394 * @param array $context Lookup tables (cf_disabled, default_meta_map, …).
395 * @return array Augmented attributes.
396 */
397 function wppb_fb_compute_field_attributes( array $attributes, $field_type, $block_name, array $context ) {
398 $cf_disabled = isset( $context['cf_disabled'] ) ? (array) $context['cf_disabled'] : array();
399 $default_meta_map = isset( $context['default_meta_map'] ) ? (array) $context['default_meta_map'] : array();
400 $no_meta_name_types = isset( $context['no_meta_name_types'] ) ? (array) $context['no_meta_name_types'] : array();
401 $context_label = isset( $context['context_label'] ) ? (string) $context['context_label'] : 'register';
402
403 // CL attrs for types that can host rules.
404 if ( ! in_array( $field_type, $cf_disabled, true ) ) {
405 if ( ! isset( $attributes['conditional-logic-enabled'] ) ) {
406 $attributes['conditional-logic-enabled'] = array( 'type' => 'string', 'default' => '' );
407 }
408 if ( ! isset( $attributes['conditional-logic'] ) ) {
409 $attributes['conditional-logic'] = array( 'type' => 'string', 'default' => '' );
410 }
411 }
412
413 // Defaults: canonical readonly; custom storable: '' + overwrite; else neither.
414 $is_default = array_key_exists( $field_type, $default_meta_map );
415 $carries_meta = $is_default
416 ? $default_meta_map[ $field_type ] !== ''
417 : ! in_array( $field_type, $no_meta_name_types, true );
418
419 if ( $is_default && $carries_meta ) {
420 $attributes['meta-name'] = array(
421 'type' => 'string',
422 'default' => $default_meta_map[ $field_type ],
423 );
424 } elseif ( ! $is_default && $carries_meta ) {
425 if ( ! isset( $attributes['meta-name'] ) ) {
426 $attributes['meta-name'] = array( 'type' => 'string', 'default' => '' );
427 }
428 $attributes['overwrite-existing'] = array( 'type' => 'string', 'default' => 'No' );
429 }
430
431 // Add-on per-field-property attrs (Field Visibility, EPAA approval flag, …).
432 return apply_filters(
433 'wppb_fb_block_attributes',
434 $attributes,
435 $field_type,
436 array( 'context' => $context_label, 'block_name' => $block_name )
437 );
438 }
439
440 /**
441 * Resolve canonical meta-name for a row. Single entry for flatten + REST PUT paths.
442 *
443 * @param string $raw User-supplied meta-name (may be empty).
444 * @param string $previous_value Prior meta-name (rename-tolerant sanitization).
445 * @param string $field_type Field type display name.
446 * @param array $manage Working copy of wppb_manage_fields.
447 * @param array $claimed Already-claimed names (excluding self) → true.
448 * @param array $opts Optional: field_title, is_subfield, group, …
449 * @return string Final meta-name ('' when the type has none).
450 */
451 function wppb_fb_resolve_meta_name( $raw, $previous_value, $field_type, $manage, $claimed, $opts = array() ) {
452 $default_meta_map = wppb_fb_default_field_meta_names();
453 $fixed_meta_map = wppb_fb_fixed_meta_name_field_types();
454 $no_meta_name_types = wppb_fb_no_meta_name_field_types();
455
456 if ( array_key_exists( $field_type, $default_meta_map ) ) {
457 return $default_meta_map[ $field_type ];
458 }
459 // Fixed key: return verbatim so it never drifts.
460 if ( array_key_exists( $field_type, $fixed_meta_map ) ) {
461 return $fixed_meta_map[ $field_type ];
462 }
463 if ( in_array( $field_type, $no_meta_name_types, true ) ) {
464 return '';
465 }
466
467 $is_subfield = ! empty( $opts['is_subfield'] );
468 $field_title = isset( $opts['field_title'] ) ? (string) $opts['field_title'] : '';
469 $current_repeater_meta = isset( $opts['current_repeater_meta'] ) ? (string) $opts['current_repeater_meta'] : null;
470 $extra_known_metas = isset( $opts['extra_known_metas'] ) ? (array) $opts['extra_known_metas'] : array();
471 // Overwrite-existing keeps a duplicate meta-name, as in the classic admin.
472 $allow_duplicate = ! empty( $opts['overwrite_existing'] );
473 $state = isset( $opts['global_subfield_state'] ) && is_array( $opts['global_subfield_state'] )
474 ? $opts['global_subfield_state']
475 : null;
476
477 if ( $is_subfield ) {
478 // Sub-field: sanitize against group; collision-check vs global rows.
479 $group = isset( $opts['group'] ) && is_array( $opts['group'] ) ? $opts['group'] : array();
480 $clean = wppb_fb_sanitize_meta_name( (string) $raw, $field_type, $previous_value, $claimed, $group, $allow_duplicate );
481 if ( $clean === '' ) {
482 $clean = ( $previous_value !== '' ) ? $previous_value : wppb_fb_next_meta_name( $group );
483 }
484 return wppb_fb_break_runtime_collision( $clean, true, $manage, $current_repeater_meta, $extra_known_metas, $state, $previous_value );
485 }
486
487 if ( $field_type === 'Repeater' ) {
488 // No generic dedup: Repeater meta-name IS the option key. Suffix slug instead.
489 $clean = wppb_fb_sanitize_meta_name( (string) $raw, $field_type, $previous_value, $claimed, $manage, true );
490 if ( $clean === '' ) {
491 $clean = ( $previous_value !== '' && strpos( $previous_value, 'wppb_repeater_field_' ) === 0 )
492 ? $previous_value
493 : wppb_fb_default_repeater_meta_name( $field_title );
494 }
495 // Keep the `wppb_repeater_field_` prefix, suffixing `_2`, `_3`, … until
496 // free. Skipped for overwrite-existing or an unchanged name (a re-save).
497 if ( ! $allow_duplicate && $clean !== $previous_value && isset( $claimed[ $clean ] ) ) {
498 $base = $clean;
499 $n = 2;
500 do {
501 $clean = $base . '_' . $n;
502 $n++;
503 } while ( isset( $claimed[ $clean ] ) );
504 }
505 return wppb_fb_break_runtime_collision( $clean, false, $manage, null, array(), $state, $previous_value );
506 }
507
508 // Custom storable field.
509 $clean = wppb_fb_sanitize_meta_name( (string) $raw, $field_type, $previous_value, $claimed, $manage, $allow_duplicate );
510 if ( $clean === '' ) {
511 // Include sub-field names: set-0 runtime key is the bare meta-name.
512 $clean = ( $previous_value !== '' )
513 ? $previous_value
514 : wppb_fb_next_meta_name( $manage, 'custom_field_', wppb_fb_collect_global_subfield_metanames( $manage, null, $state ) );
515 }
516 return wppb_fb_break_runtime_collision( $clean, false, $manage, null, array(), $state, $previous_value );
517 }
518
519 /**
520 * Sanitize a CL scalar. Legacy renderer concatenates values into double-quoted JS —
521 * strip `"`, `\`, and `<>` on write (`sanitize_text_field` leaves quotes).
522 */
523 function wppb_fb_sanitize_cl_scalar( $v ) {
524 if ( is_int( $v ) || is_float( $v ) || is_bool( $v ) ) {
525 return $v;
526 }
527 $v = sanitize_text_field( (string) $v );
528 return str_replace( array( '"', '\\', '<', '>' ), '', $v );
529 }
530
531 /**
532 * True when a CL rule names a real source field. Empty/`-1` placeholders poison
533 * the whole field under logic_type=all — drop them on write.
534 *
535 * @param array $rule Decoded rule row.
536 * @return bool
537 */
538 function wppb_fb_cl_rule_has_source( $rule ) {
539 if ( ! is_array( $rule ) || ! isset( $rule['field'] ) || ! is_scalar( $rule['field'] ) ) {
540 return false;
541 }
542 $field = trim( (string) $rule['field'] );
543 return $field !== '' && $field !== '-1' && $field !== '0';
544 }
545
546 /**
547 * Sanitize CL JSON: scalar-clean each rule, drop sourceless rules, re-encode.
548 * Malformed input falls back to sanitize_text_field().
549 */
550 function wppb_fb_sanitize_conditional_logic_json( $value ) {
551 if ( ! is_string( $value ) || $value === '' ) {
552 return '';
553 }
554 $data = json_decode( $value, true );
555 if ( ! is_array( $data ) ) {
556 // Not the expected JSON — never store it raw (it still reaches the sink).
557 return sanitize_text_field( $value );
558 }
559 if ( isset( $data['rules'] ) && is_array( $data['rules'] ) ) {
560 $clean_rules = array();
561 foreach ( $data['rules'] as $rule ) {
562 if ( ! is_array( $rule ) ) {
563 continue;
564 }
565 foreach ( array( 'field', 'operator', 'value' ) as $rule_key ) {
566 if ( isset( $rule[ $rule_key ] ) && is_scalar( $rule[ $rule_key ] ) ) {
567 $rule[ $rule_key ] = wppb_fb_sanitize_cl_scalar( $rule[ $rule_key ] );
568 }
569 }
570 if ( ! wppb_fb_cl_rule_has_source( $rule ) ) {
571 continue;
572 }
573 $clean_rules[] = $rule;
574 }
575 // Re-index so the JSON stays an array, not an object with holes.
576 $data['rules'] = $clean_rules;
577 }
578 // Whitelist action_type/logic_type only when present (renderer emits them raw).
579 if ( isset( $data['action_type'] ) ) {
580 $data['action_type'] = ( $data['action_type'] === 'hide' ) ? 'hide' : 'show';
581 }
582 if ( isset( $data['logic_type'] ) ) {
583 $data['logic_type'] = ( $data['logic_type'] === 'any' ) ? 'any' : 'all';
584 }
585 return wp_json_encode( $data );
586 }
587
588 /**
589 * Seed mandatory legacy keys from registry defaults when the row omits them
590 * (editor serializes only non-defaults). Seed only keys read UNCONDITIONALLY.
591 */
592 function wppb_fb_seed_legacy_row_keys( array $row, $field_type, array $excluded = array() ) {
593 $type_attrs = array();
594 if ( $field_type !== '' ) {
595 $registry = WPPB_FB_Field_Registry::all();
596 if ( isset( $registry[ $field_type ]['attributes'] ) && is_array( $registry[ $field_type ]['attributes'] ) ) {
597 $type_attrs = $registry[ $field_type ]['attributes'];
598 }
599 }
600
601 foreach ( wppb_fb_always_seeded_legacy_row_keys() as $legacy_key => $fallback ) {
602 if ( array_key_exists( $legacy_key, $row ) ) continue;
603 if ( in_array( $legacy_key, $excluded, true ) ) continue;
604 $row[ $legacy_key ] = isset( $type_attrs[ $legacy_key ]['default'] )
605 ? (string) $type_attrs[ $legacy_key ]['default']
606 : $fallback;
607 }
608
609 foreach ( wppb_fb_seeded_legacy_row_keys( $field_type ) as $legacy_key ) {
610 if ( array_key_exists( $legacy_key, $row ) ) continue;
611 if ( in_array( $legacy_key, $excluded, true ) ) continue;
612 if ( ! isset( $type_attrs[ $legacy_key ]['default'] ) ) continue;
613 $row[ $legacy_key ] = (string) $type_attrs[ $legacy_key ]['default'];
614 }
615
616 return $row;
617 }
618
619 /**
620 * Type-independent legacy keys every row must have (classic `$common_properties`).
621 * `overwrite-existing` omitted — no unguarded reader of a stored row.
622 *
623 * @return array Map of legacy row key => fallback value.
624 */
625 function wppb_fb_always_seeded_legacy_row_keys() {
626 return array(
627 'field-title' => '',
628 'description' => '',
629 'required' => 'No',
630 );
631 }
632
633 /**
634 * Per-type legacy keys seeded only when the block declares them (unguarded readers).
635 *
636 * @param string $field_type Field type display name.
637 * @return array Legacy row keys to seed.
638 */
639 function wppb_fb_seeded_legacy_row_keys( $field_type = '' ) {
640 // Unguarded readers; description/required are type-independent.
641 $keys = array(
642 // default-fields/input (and most other renderers).
643 'default-value',
644 // default-fields/textarea, description; extra-fields/wysiwyg.
645 'default-content', 'row-count',
646 'default-option', 'default-options', 'options', 'labels', 'cpt', 'taxonomy',
647 // extra-fields/select-country|currency|timezone.
648 'default-option-country', 'default-option-currency', 'default-option-timezone',
649 // default-fields/heading; extra-fields/html.
650 'heading-tag', 'html-content',
651 // extra-fields/datepicker, timepicker, phone.
652 'date-format', 'time-format', 'phone-format',
653 // default-fields/upload (upload_helper_functions); extra-fields/upload.
654 'avatar-size', 'max-file-size', 'simple-upload',
655 'allowed-image-extensions', 'allowed-upload-extensions',
656 // extra-fields/number.
657 'min-number-value', 'max-number-value', 'number-step-value',
658 // extra-fields/select2-multiple.
659 'select2-multiple-limit',
660 // default-fields/user-role.
661 'user-roles',
662 // extra-fields/validation.
663 'validation-possible-values', 'custom-error-message',
664 // default-fields/recaptcha; extra-fields/turnstile.
665 'recaptcha-type', 'public-key', 'private-key',
666 'turnstile-site-key', 'turnstile-secret-key',
667 // extra-fields/map.
668 'map-api-key',
669 'rpf-limit', 'rpf-enable-limit',
670 // gdpr-communication-preferences: explode() on every render.
671 'gdpr-communication-preferences',
672 // MailPoet on every edit-profile render; the other two on unsubscribe.
673 'mailpoet-lists', 'mailchimp-lists', 'campaign-monitor-lists',
674 );
675
676 /**
677 * Filter seeded legacy row keys for a field type.
678 *
679 * @param array $keys Legacy row keys to seed.
680 * @param string $field_type Field type display name.
681 */
682 return apply_filters( 'wppb_fb_seeded_legacy_row_keys', $keys, $field_type );
683 }
684
685 /**
686 * Apply an attribute patch and resolve meta-name. Single entry for flatten + REST PUTs
687 * so excluded-attrs and meta-name policy stay uniform.
688 *
689 * @param array $row Existing or freshly allocated row.
690 * @param array $patch Attribute map to apply.
691 * @param array $context Resolver context (manage, claimed, …).
692 * @return array Updated row.
693 */
694 function wppb_fb_apply_field_row_patch( array $row, array $patch, array $context ) {
695 $field_type = isset( $context['field_type'] ) ? (string) $context['field_type'] : '';
696 $manage = isset( $context['manage'] ) && is_array( $context['manage'] ) ? $context['manage'] : array();
697 $claimed = isset( $context['claimed'] ) && is_array( $context['claimed'] ) ? $context['claimed'] : array();
698 $is_subfield = ! empty( $context['is_subfield'] );
699
700 // The default excluded set differs by row scope: per-form cross-cutting attrs
701 // for a top-level row, the Repeater-stripped attrs for a sub-field row.
702 $excluded = isset( $context['excluded_attrs'] ) && is_array( $context['excluded_attrs'] )
703 ? $context['excluded_attrs']
704 : ( $is_subfield
705 ? (array) wppb_fb_repeater_subfield_stripped_attrs()
706 : (array) wppb_fb_excluded_manage_fields_attrs() );
707
708 // `id` and `field` are immutable identity; `meta-name` is resolved below, and
709 // skipping it here keeps the resolver's value winning whatever the patch order.
710 foreach ( $patch as $key => $value ) {
711 if ( $key === 'id' || $key === 'field' || $key === 'meta-name' ) continue;
712 if ( in_array( $key, $excluded, true ) ) continue;
713 if ( $key === 'conditional-logic' ) {
714 // Security: rule values reach an inline <script> on the legacy front
715 // end. See wppb_fb_sanitize_cl_scalar() for the threat model.
716 $row[ $key ] = wppb_fb_sanitize_conditional_logic_json( $value );
717 continue;
718 }
719 if ( is_scalar( $value ) || $value === null ) {
720 $row[ $key ] = (string) $value;
721 } else {
722 $row[ $key ] = $value;
723 }
724 }
725
726 // Ensure the field type is correct (the caller may have just created the row).
727 if ( $field_type !== '' ) $row['field'] = $field_type;
728
729 // Select (User Role) explode( ', ', … ) needs the legacy separator.
730 $effective_type = ( isset( $row['field'] ) && $row['field'] !== '' ) ? (string) $row['field'] : $field_type;
731 if ( $effective_type === 'Select (User Role)' ) {
732 foreach ( array( 'user-roles', 'user-roles-sort-order' ) as $roles_key ) {
733 if ( array_key_exists( $roles_key, $row ) && is_string( $row[ $roles_key ] ) && $row[ $roles_key ] !== '' ) {
734 $row[ $roles_key ] = wppb_fb_normalize_role_slug_csv( $row[ $roles_key ] );
735 }
736 }
737 }
738
739 // Seed the legacy keys the block omitted because they sat at their default.
740 $row = wppb_fb_seed_legacy_row_keys( $row, $field_type, $excluded );
741
742 if ( isset( $context['resolve_meta_name'] ) && $context['resolve_meta_name'] === false ) {
743 return $row;
744 }
745
746 $patch_has_meta = array_key_exists( 'meta-name', $patch );
747 $previous_meta = isset( $row['meta-name'] ) ? (string) $row['meta-name'] : '';
748 $raw = $patch_has_meta ? (string) $patch['meta-name'] : $previous_meta;
749
750 // Fixed-key defaults: mirror PUTs diffs only — seed so new rows don't get custom_field_N.
751 if ( ! $patch_has_meta && $previous_meta === '' && $field_type !== '' ) {
752 $seed_registry = WPPB_FB_Field_Registry::all();
753 if ( ! empty( $seed_registry[ $field_type ]['attributes']['meta-name']['default'] ) ) {
754 $raw = (string) $seed_registry[ $field_type ]['attributes']['meta-name']['default'];
755 }
756 }
757
758 // Read off the already-patched row so the current toggle state wins whether it
759 // arrived in this patch or was carried on the existing row.
760 $overwrite_existing = isset( $row['overwrite-existing'] ) && $row['overwrite-existing'] === 'Yes';
761
762 $resolve_opts = array(
763 'is_subfield' => $is_subfield,
764 'field_title' => isset( $context['field_title'] ) ? (string) $context['field_title'] : '',
765 'group' => isset( $context['group'] ) ? (array) $context['group'] : array(),
766 'current_repeater_meta' => isset( $context['current_repeater_meta'] ) ? (string) $context['current_repeater_meta'] : null,
767 'extra_known_metas' => isset( $context['extra_known_metas'] ) ? (array) $context['extra_known_metas'] : array(),
768 'overwrite_existing' => $overwrite_existing,
769 'global_subfield_state' => isset( $context['global_subfield_state'] ) && is_array( $context['global_subfield_state'] )
770 ? $context['global_subfield_state']
771 : null,
772 );
773
774 $row['meta-name'] = wppb_fb_resolve_meta_name( $raw, $previous_meta, $field_type, $manage, $claimed, $resolve_opts );
775
776 return $row;
777 }
778
779 /**
780 * Normalize a slug CSV to legacy ', ' (front end explode). Idempotent.
781 *
782 * @param string $csv Raw comma-separated slug list.
783 * @return string
784 */
785 function wppb_fb_normalize_role_slug_csv( $csv ) {
786 $parts = array_map( 'trim', explode( ',', (string) $csv ) );
787 $parts = array_values( array_filter( $parts, static function ( $p ) {
788 return $p !== '';
789 } ) );
790 return implode( ', ', $parts );
791 }
792