PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.1
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.1
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-projection.php

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

788 lines 32.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Save-side projection: Gutenberg block trees → legacy field storage.
4 * Entry: wppb_fb_project_blocks_to_meta(); walker: wppb_fb_flatten_blocks().
5 */
6
7 if ( ! defined( 'ABSPATH' ) ) exit;
8
9 // 1. Cross-cutting policy: excluded attrs, Repeater allow/exclude/strip
10
11 /**
12 * Attribute names excluded from wppb_manage_fields rows.
13 * For per-form attrs that must not hit the shared global row.
14 */
15 function wppb_fb_excluded_manage_fields_attrs() {
16 return apply_filters( 'wppb_fb_excluded_manage_fields_attrs', array( 'id' ) );
17 }
18
19 /**
20 * Field types banned as Repeater children (matches repeater add-on list).
21 * Defers to add-on when loaded. Bans Default-* fields: indexed keys hit
22 * user_meta and bypass wp_update_user().
23 */
24 function wppb_fb_repeater_excluded_field_types() {
25 if ( function_exists( 'wppb_rpf_manage_fields_get_excluded_fields' ) ) {
26 return wppb_rpf_manage_fields_get_excluded_fields();
27 }
28
29 return apply_filters( 'wppb_rpf_manage_fields_get_excluded_fields', wppb_fb_repeater_excluded_field_types_fallback() );
30 }
31
32 /** Fallback when repeater add-on is not loaded (admin-only include). Hand-mirrored; test diffs when add-on loads. */
33 function wppb_fb_repeater_excluded_field_types_fallback() {
34 return array(
35 'Default - Name (Heading)',
36 'Default - Contact Info (Heading)',
37 'Default - About Yourself (Heading)',
38 'Default - Username',
39 'Default - First Name',
40 'Default - Last Name',
41 'Default - Nickname',
42 'Default - E-mail',
43 'Default - Website',
44 'Default - AIM',
45 'Default - Yahoo IM',
46 'Default - Jabber / Google Talk',
47 'Default - Password',
48 'Default - Repeat Password',
49 'Default - Biographical Info',
50 'Default - Display name publicly as',
51 'Default - Blog Details',
52 'Select (User Role)',
53 'WYSIWYG',
54 'Avatar',
55 'reCAPTCHA',
56 'MailChimp Subscribe',
57 'MailPoet Subscribe',
58 'Campaign Monitor Subscribe',
59 'Email Confirmation',
60 'WooCommerce Customer Billing Address',
61 'WooCommerce Customer Shipping Address',
62 'Subscription Plans',
63 'Repeater',
64 'GDPR Checkbox',
65 'GDPR Delete Button',
66 'Checkbox (Terms and Conditions)',
67 );
68 }
69
70 /** Registered field blocks minus repeater-excluded types. Filterable. */
71 function wppb_fb_repeater_allowed_block_names() {
72 $excluded = wppb_fb_repeater_excluded_field_types();
73 $registry = WPPB_FB_Field_Registry::all();
74 $block_names = array();
75 foreach ( $registry as $field_type => $entry ) {
76 if ( in_array( $field_type, $excluded, true ) ) continue;
77 if ( ! empty( $entry['block'] ) ) {
78 $block_names[] = $entry['block'];
79 }
80 }
81 // Final overlay, so an add-on can adjust the result without the type list.
82 return array_values( apply_filters( 'wppb_fb_repeater_allowed_block_names', $block_names ) );
83 }
84
85 /**
86 * Sub-field attrs stripped before Repeater option write.
87 * Conditional Logic runs on parent only. Filterable.
88 */
89 function wppb_fb_repeater_subfield_stripped_attrs() {
90 return apply_filters( 'wppb_fb_repeater_subfield_stripped_attrs', array(
91 'conditional-logic-enabled',
92 'conditional-logic',
93 ) );
94 }
95
96 /** Whether Repeater Fields module is on. Off = no-op; legacy data stays intact when re-enabled. */
97 function wppb_fb_is_repeater_module_active() {
98 $settings = get_option( 'wppb_module_settings', 'not_found' );
99 if ( $settings === 'not_found' || ! is_array( $settings ) ) return false;
100 return isset( $settings['wppb_repeaterFields'] ) && $settings['wppb_repeaterFields'] === 'show';
101 }
102
103 // 2. Dedup pre-walk
104
105 /**
106 * Reset duplicate non-zero IDs to 0 for re-allocation.
107 * Gutenberg "Duplicate block" copies id; without this the copy clobbers the row.
108 */
109 function wppb_fb_dedup_block_ids( &$blocks, &$seen = array() ) {
110 foreach ( $blocks as &$block ) {
111 if ( ! empty( $block['blockName'] ) && strpos( $block['blockName'], 'profile-builder/' ) === 0 ) {
112 $id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0;
113 if ( $id > 0 ) {
114 if ( isset( $seen[ $id ] ) ) {
115 $block['attrs']['id'] = 0;
116 } else {
117 $seen[ $id ] = true;
118 }
119 }
120 }
121 if ( ! empty( $block['innerBlocks'] ) ) {
122 wppb_fb_dedup_block_ids( $block['innerBlocks'], $seen );
123 }
124 }
125 unset( $block );
126 }
127
128 // 3. Save projection (blocks -> meta)
129
130 /** Import guard. Import/Export uses empty post_content and restores field list from meta. */
131 function wppb_fb_set_importing( $state ) {
132 $GLOBALS['wppb_fb_importing'] = (bool) $state;
133 }
134 function wppb_fb_is_importing() {
135 return ! empty( $GLOBALS['wppb_fb_importing'] );
136 }
137
138 /**
139 * Flag that this save carried editor content.
140 * post_content is always blank for form CPTs, so empty tree ≠ cleared canvas
141 * without this. Hook rest_pre_insert_* (before save_post), not after.
142 */
143 add_filter( 'rest_pre_insert_wppb-rf-cpt', 'wppb_fb_flag_editor_content_save', 10, 2 );
144 add_filter( 'rest_pre_insert_wppb-epf-cpt', 'wppb_fb_flag_editor_content_save', 10, 2 );
145 function wppb_fb_flag_editor_content_save( $prepared_post, $request ) {
146 if ( $request instanceof WP_REST_Request && $request->has_param( 'content' ) ) {
147 $GLOBALS['wppb_fb_editor_content_save'] = true;
148 }
149 return $prepared_post;
150 }
151
152 /** Whether this request carried editor content (REST filter or classic POST). */
153 function wppb_fb_save_carries_editor_content() {
154 return ! empty( $GLOBALS['wppb_fb_editor_content_save'] ) || isset( $_POST['content'] );
155 }
156
157 function wppb_fb_tree_has_pb_block( $blocks ) {
158 foreach ( (array) $blocks as $block ) {
159 if ( ! empty( $block['blockName'] ) && strpos( $block['blockName'], 'profile-builder/' ) === 0 ) {
160 return true;
161 }
162 if ( ! empty( $block['innerBlocks'] ) && wppb_fb_tree_has_pb_block( $block['innerBlocks'] ) ) {
163 return true;
164 }
165 }
166 return false;
167 }
168
169 add_action( 'save_post_wppb-rf-cpt', 'wppb_fb_project_blocks_to_meta', 20, 2 );
170 add_action( 'save_post_wppb-epf-cpt', 'wppb_fb_project_blocks_to_meta', 20, 2 );
171 function wppb_fb_project_blocks_to_meta( $post_id, $post ) {
172 if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
173 /**
174 * Fires when the projection bails on a guard.
175 *
176 * @param int $post_id Post that triggered the projection.
177 * @param string $reason Guard name that stopped the write.
178 */
179 do_action( 'wppb_fb_projection_skipped', $post_id, 'revision_or_autosave' );
180 return;
181 }
182 if ( ! in_array( $post->post_type, array( 'wppb-rf-cpt', 'wppb-epf-cpt' ), true ) ) {
183 do_action( 'wppb_fb_projection_skipped', $post_id, 'wrong_cpt' );
184 return;
185 }
186
187 // Classic mode: the WCK metaboxes own the save flow, and projecting their
188 // empty post_content would wipe wppb_*_fields.
189 if ( ! wppb_fb_is_active_for( $post->post_type ) ) {
190 do_action( 'wppb_fb_projection_skipped', $post_id, 'classic_editor_mode' );
191 return;
192 }
193
194 // meta-box-loader re-fires save_post with empty content — would wipe fields.
195 if ( ! empty( $_REQUEST['meta-box-loader'] ) ) {
196 do_action( 'wppb_fb_projection_skipped', $post_id, 'meta_box_loader' );
197 return;
198 }
199
200 if ( wppb_fb_is_importing() ) {
201 do_action( 'wppb_fb_projection_skipped', $post_id, 'importing' );
202 return;
203 }
204
205 // No editor content: post_content is blank for form CPTs — projecting would wipe fields.
206 $blocks_parsed = parse_blocks( $post->post_content );
207 if ( ! wppb_fb_tree_has_pb_block( $blocks_parsed ) && ! wppb_fb_save_carries_editor_content() ) {
208 do_action( 'wppb_fb_projection_skipped', $post_id, 'no_editor_content' );
209 return;
210 }
211
212 static $done = array();
213 if ( isset( $done[ $post_id ] ) ) {
214 do_action( 'wppb_fb_projection_skipped', $post_id, 'reentrancy' );
215 return;
216 }
217 $done[ $post_id ] = true;
218
219 // 1. Blank post_content in the database
220 if ( $post->post_content !== '' ) {
221 $blanked = wp_update_post( array( 'ID' => $post_id, 'post_content' => '' ), true );
222 if ( is_wp_error( $blanked ) ) {
223 // Update short-circuited; next non-editor save could re-project stale markup.
224 do_action( 'wppb_fb_content_blank_failed', $post_id, $blanked );
225 }
226 }
227
228 wppb_fb_dedup_block_ids( $blocks_parsed );
229
230 $ordered_list = array();
231 $manage = wppb_fb_manage_fields();
232
233 // Index manage_fields by ID for updates
234 $manage_by_id = array();
235 foreach ( $manage as $idx => $item ) {
236 if ( isset( $item['id'] ) ) $manage_by_id[ (int) $item['id'] ] = $idx;
237 }
238
239 // Track meta-names already claimed in this save so the sanitizer can
240 // detect cross-block collisions.
241 $claimed_meta_names = array();
242
243 // Seed $claimed with out-of-tree global meta-names (uniqueness is site-wide).
244 $tree_field_ids = wppb_fb_collect_tree_field_ids( $blocks_parsed );
245 foreach ( $manage as $m_row ) {
246 if ( empty( $m_row['meta-name'] ) ) continue;
247 $m_id = isset( $m_row['id'] ) ? (int) $m_row['id'] : 0;
248 if ( $m_id > 0 && isset( $tree_field_ids[ $m_id ] ) ) continue; // in this form → resolved during the walk
249 $claimed_meta_names[ (string) $m_row['meta-name'] ] = true;
250 }
251
252 // Collected once so the meta-name resolver and the ID allocator reuse it
253 // instead of each re-reading every Repeater's option.
254 $global_subfield_state = wppb_fb_collect_global_subfield_state( $manage );
255
256 $context = array(
257 'post_id' => (int) $post_id,
258 'post_type' => $post->post_type,
259 'global_subfield_state' => $global_subfield_state,
260 );
261
262 wppb_fb_flatten_blocks( $blocks_parsed, $ordered_list, $manage, $manage_by_id, $claimed_meta_names, $context );
263
264 update_option( 'wppb_manage_fields', $manage );
265
266 $meta_key = $post->post_type === 'wppb-rf-cpt' ? 'wppb_rf_fields' : 'wppb_epf_fields';
267
268 // Entries that were unrenderable on read never reached the canvas, so the
269 // canvas-rebuilt list must not treat their absence as a removal (see below).
270 $previous_list = get_post_meta( $post_id, $meta_key, true );
271 if ( is_array( $previous_list ) && ! empty( $previous_list ) ) {
272 $ordered_list = wppb_fb_carry_forward_unrenderable_entries( $ordered_list, $previous_list, $manage );
273 }
274
275 update_post_meta( $post_id, $meta_key, $ordered_list );
276
277 // Add-ons flush per-form state accumulated during flatten.
278 do_action( 'wppb_fb_post_project', (int) $post_id, $post, $context );
279 }
280
281 /**
282 * Re-insert unrenderable entries the canvas omitted (Repeater off / no block).
283 * Survivors keep position after their nearest surviving predecessor.
284 *
285 * @param array $ordered_list List rebuilt from the canvas this save.
286 * @param array $previous_list Stored per-form list from before this save.
287 * @param array $manage Post-flatten wppb_manage_fields working copy.
288 * @return array
289 */
290 function wppb_fb_carry_forward_unrenderable_entries( $ordered_list, $previous_list, $manage ) {
291 $previous_list = array_values( $previous_list );
292
293 $new_ids = array();
294 foreach ( $ordered_list as $entry ) {
295 if ( isset( $entry['id'] ) ) $new_ids[ (int) $entry['id'] ] = true;
296 }
297
298 $manage_by_id = array();
299 foreach ( $manage as $row ) {
300 if ( isset( $row['id'] ) ) $manage_by_id[ (int) $row['id'] ] = $row;
301 }
302
303 $registry = WPPB_FB_Field_Registry::all();
304 $repeater_active = wppb_fb_is_repeater_module_active();
305
306 foreach ( $previous_list as $pos => $entry ) {
307 if ( ! isset( $entry['id'] ) ) continue;
308 $id = (int) $entry['id'];
309
310 if ( isset( $new_ids[ $id ] ) ) continue; // re-emitted from the canvas
311 if ( ! isset( $manage_by_id[ $id ] ) ) continue; // globally deleted → genuinely gone
312
313 $type = isset( $manage_by_id[ $id ]['field'] ) ? $manage_by_id[ $id ]['field'] : '';
314 $unrenderable =
315 ( ! $repeater_active && $type === 'Repeater' ) ||
316 ( ! isset( $registry[ $type ] ) );
317 if ( ! $unrenderable ) continue; // renderable but absent → user removed it
318
319 // Nearest preceding previous-list entry that survived into the new list.
320 $anchor_id = null;
321 for ( $j = $pos - 1; $j >= 0; $j-- ) {
322 if ( isset( $previous_list[ $j ]['id'] ) && isset( $new_ids[ (int) $previous_list[ $j ]['id'] ] ) ) {
323 $anchor_id = (int) $previous_list[ $j ]['id'];
324 break;
325 }
326 }
327
328 $insert_at = 0;
329 if ( $anchor_id !== null ) {
330 foreach ( $ordered_list as $k => $e ) {
331 if ( isset( $e['id'] ) && (int) $e['id'] === $anchor_id ) { $insert_at = $k + 1; break; }
332 }
333 }
334
335 array_splice( $ordered_list, $insert_at, 0, array( $entry ) );
336 $new_ids[ $id ] = true; // a following carried entry may anchor on this one
337 }
338
339 return $ordered_list;
340 }
341
342 /**
343 * Re-insert unrenderable Repeater sub-rows the canvas omitted (no registered block).
344 *
345 * @param array $group Group rebuilt from the canvas this save.
346 * @param array $existing_group Stored per-Repeater option from before this save.
347 * @return array
348 */
349 function wppb_fb_carry_forward_unrenderable_subfields( $group, $existing_group ) {
350 $existing_group = array_values( $existing_group );
351 $registry = WPPB_FB_Field_Registry::all();
352
353 $present_ids = array();
354 foreach ( $group as $row ) {
355 if ( isset( $row['id'] ) ) $present_ids[ (int) $row['id'] ] = true;
356 }
357
358 foreach ( $existing_group as $pos => $row ) {
359 if ( ! is_array( $row ) || ! isset( $row['id'] ) ) continue;
360 $id = (int) $row['id'];
361
362 if ( isset( $present_ids[ $id ] ) ) continue; // re-emitted from the canvas
363
364 $type = isset( $row['field'] ) ? (string) $row['field'] : '';
365 if ( $type === '' || isset( $registry[ $type ] ) ) continue; // renderable but absent → user removed it
366
367 // Nearest preceding stored row that survived into the rebuilt group.
368 $anchor_id = null;
369 for ( $j = $pos - 1; $j >= 0; $j-- ) {
370 if ( isset( $existing_group[ $j ]['id'] ) && isset( $present_ids[ (int) $existing_group[ $j ]['id'] ] ) ) {
371 $anchor_id = (int) $existing_group[ $j ]['id'];
372 break;
373 }
374 }
375
376 $insert_at = 0;
377 if ( $anchor_id !== null ) {
378 foreach ( $group as $k => $e ) {
379 if ( isset( $e['id'] ) && (int) $e['id'] === $anchor_id ) { $insert_at = $k + 1; break; }
380 }
381 }
382
383 array_splice( $group, $insert_at, 0, array( $row ) );
384 $present_ids[ $id ] = true; // a following carried row may anchor on this one
385 }
386
387 return $group;
388 }
389
390 /**
391 * Field ids in a parsed tree (for seeding global meta-name uniqueness).
392 *
393 * @param array $blocks Parsed block tree.
394 * @return array Map of int field id => true.
395 */
396 function wppb_fb_collect_tree_field_ids( $blocks ) {
397 $ids = array();
398 foreach ( (array) $blocks as $block ) {
399 if ( ! empty( $block['blockName'] ) && strpos( $block['blockName'], 'profile-builder/' ) === 0 ) {
400 if ( ! empty( $block['attrs']['id'] ) ) {
401 $ids[ (int) $block['attrs']['id'] ] = true;
402 }
403 }
404 if ( ! empty( $block['innerBlocks'] ) ) {
405 $ids += wppb_fb_collect_tree_field_ids( $block['innerBlocks'] );
406 }
407 }
408 return $ids;
409 }
410
411 /**
412 * Flatten a block tree into the per-form field list and upsert global definitions.
413 * Repeater innerBlocks go to a per-meta-name option, not the ordered list.
414 */
415 function wppb_fb_flatten_blocks( $blocks, &$ordered_list, &$manage, &$manage_by_id, &$claimed_meta_names, $context = array() ) {
416 // ArrayObject so recursive $context copies share uniqueness state.
417 if ( ! isset( $context['_uniqueness'] ) ) {
418 $context['_uniqueness'] = new ArrayObject( array(
419 'seen_unique' => array(),
420 'seen_exclusive' => array(),
421 'unique_types' => wppb_fb_unique_field_types(),
422 'exclusive_groups'=> wppb_fb_exclusive_field_groups(),
423 ) );
424 }
425 $uniqueness = $context['_uniqueness'];
426
427 foreach ( $blocks as $block ) {
428 if ( empty( $block['blockName'] ) ) continue;
429 if ( strpos( $block['blockName'], 'profile-builder/' ) !== 0 ) continue;
430
431 // Before registry lookup: by_block_name still hits with module off.
432 if ( $block['blockName'] === 'profile-builder/field-repeater'
433 && ! wppb_fb_is_repeater_module_active()
434 ) {
435 continue;
436 }
437
438 // Every PB block in source order, before any field-specific processing —
439 // this is how add-ons see structural blocks and their adjacency.
440 do_action( 'wppb_fb_pb_block_walked', $block, $context );
441
442 $lookup = WPPB_FB_Field_Registry::by_block_name( $block['blockName'] );
443 if ( ! $lookup ) {
444 // Unrecognised PB block = container; children flatten as siblings.
445 if ( ! empty( $block['innerBlocks'] ) ) {
446 $inner_context = array_merge( $context, array( 'parent_block_name' => $block['blockName'] ) );
447 wppb_fb_flatten_blocks( $block['innerBlocks'], $ordered_list, $manage, $manage_by_id, $claimed_meta_names, $inner_context );
448 // Symmetric "leave" event so bridges know the wrapper closed.
449 do_action( 'wppb_fb_pb_block_walked_done', $block, $context );
450 }
451 continue;
452 }
453
454 $field_type = $lookup['field_type'];
455 $attrs = $block['attrs'];
456 $id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0;
457 $is_repeater = ( $field_type === 'Repeater' );
458
459 // Uniqueness gate: a repeat of a one-per-form type, or of an exclusive
460 // group's partner type, is skipped. First occurrence wins.
461 if ( in_array( $field_type, $uniqueness['unique_types'], true ) ) {
462 if ( isset( $uniqueness['seen_unique'][ $field_type ] ) ) {
463 continue;
464 }
465 $seen_map = $uniqueness['seen_unique'];
466 $seen_map[ $field_type ] = true;
467 $uniqueness['seen_unique'] = $seen_map;
468 }
469 $is_blocked_by_exclusive = false;
470 foreach ( $uniqueness['exclusive_groups'] as $group ) {
471 if ( ! in_array( $field_type, $group, true ) ) continue;
472 foreach ( $group as $other_type ) {
473 if ( $other_type !== $field_type && isset( $uniqueness['seen_exclusive'][ $other_type ] ) ) {
474 $is_blocked_by_exclusive = true;
475 break 2;
476 }
477 }
478 $excl_map = $uniqueness['seen_exclusive'];
479 $excl_map[ $field_type ] = true;
480 $uniqueness['seen_exclusive'] = $excl_map;
481 }
482 if ( $is_blocked_by_exclusive ) continue;
483
484 // Sub-field ids share the global id pool — fold them in for every block.
485 $extra_ids = wppb_fb_collect_global_subfield_ids( $manage, isset( $context['global_subfield_state'] ) ? $context['global_subfield_state'] : null );
486 if ( $id === 0 ) {
487 $id = wppb_fb_next_field_id( $manage, $extra_ids );
488 $manage_by_id[ $id ] = count( $manage );
489 $manage[] = array( 'id' => $id, 'field' => $field_type );
490 } elseif ( ! isset( $manage_by_id[ $id ] ) ) {
491 // Declared id with no row yet (tree authored by CLI/import, or the row
492 // was deleted globally): insert at that id rather than drop the field.
493 $manage_by_id[ $id ] = count( $manage );
494 $manage[] = array( 'id' => $id, 'field' => $field_type );
495 }
496
497 $repeater_meta_name = '';
498
499 // Schema-hydrated patch (unsupplied attributes land at their default),
500 // applied by the helper both save paths share.
501 if ( isset( $manage_by_id[ $id ] ) ) {
502 $idx = $manage_by_id[ $id ];
503
504 $patch = array();
505 foreach ( $lookup['attributes'] as $attr_name => $schema ) {
506 $patch[ $attr_name ] = isset( $attrs[ $attr_name ] )
507 ? $attrs[ $attr_name ]
508 : ( isset( $schema['default'] ) ? $schema['default'] : '' );
509 }
510 // Carry the user's raw meta-name through so the helper can sanitize
511 // it; otherwise the schema-derived blank would override a typed value.
512 if ( isset( $attrs['meta-name'] ) ) {
513 $patch['meta-name'] = $attrs['meta-name'];
514 }
515
516 // Keep stored Repeater meta-name (it IS the option key); renaming orphans data.
517 $existing_meta = isset( $manage[ $idx ]['meta-name'] ) ? (string) $manage[ $idx ]['meta-name'] : '';
518 $skip_repeater_rename = $is_repeater
519 && $existing_meta !== ''
520 && isset( $attrs['meta-name'] )
521 && (string) $attrs['meta-name'] !== $existing_meta;
522
523 $patch_context = array(
524 'field_type' => $field_type,
525 'manage' => $manage,
526 'claimed' => $claimed_meta_names,
527 'field_title' => isset( $attrs['field-title'] ) ? (string) $attrs['field-title'] : '',
528 'global_subfield_state' => isset( $context['global_subfield_state'] ) ? $context['global_subfield_state'] : null,
529 );
530 if ( $skip_repeater_rename ) {
531 $patch_context['resolve_meta_name'] = false;
532 }
533
534 $manage[ $idx ] = wppb_fb_apply_field_row_patch(
535 $manage[ $idx ],
536 $patch,
537 $patch_context
538 );
539
540 $resolved_meta = isset( $manage[ $idx ]['meta-name'] ) ? (string) $manage[ $idx ]['meta-name'] : '';
541 if ( $resolved_meta !== '' ) {
542 $claimed_meta_names[ $resolved_meta ] = true;
543 if ( $is_repeater ) $repeater_meta_name = $resolved_meta;
544 }
545 }
546
547 $title = isset( $attrs['field-title'] ) ? $attrs['field-title'] : ( isset( $lookup['attributes']['field-title']['default'] ) ? $lookup['attributes']['field-title']['default'] : '' );
548
549 $ordered_list[] = array(
550 'field' => $title . ' ( ' . $field_type . ' )',
551 'id' => $id,
552 );
553
554 // Once per field block: where add-ons harvest a cross-cutting attribute
555 // into their own per-form storage.
556 $field_context = array_merge(
557 array( 'is_subfield' => false, 'is_repeater' => $is_repeater ),
558 $context
559 );
560 do_action( 'wppb_fb_field_flattened', $id, $attrs, $field_type, $field_context );
561
562 if ( $is_repeater ) {
563 // Sub-fields go into the parent's own option, never the per-form list.
564 if ( $repeater_meta_name !== '' ) {
565 $written_group = wppb_fb_flatten_repeater_subfields(
566 isset( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(),
567 $repeater_meta_name,
568 $manage,
569 isset( $context['global_subfield_state'] ) ? $context['global_subfield_state'] : null
570 );
571 // Refresh the snapshot so a sibling Repeater flattened later in
572 // this same save sees these ids + meta-names.
573 if ( isset( $context['global_subfield_state'] ) && is_array( $context['global_subfield_state'] ) ) {
574 $context['global_subfield_state'] = wppb_fb_merge_repeater_group_into_state(
575 $context['global_subfield_state'],
576 $repeater_meta_name,
577 (array) $written_group
578 );
579 }
580 }
581 } elseif ( ! empty( $block['innerBlocks'] ) ) {
582 wppb_fb_flatten_blocks( $block['innerBlocks'], $ordered_list, $manage, $manage_by_id, $claimed_meta_names, $context );
583 }
584 }
585 }
586
587 /**
588 * Write Repeater sub-fields to update_option( $repeater_meta_name ). Returns the
589 * group so the caller can refresh the global sub-field snapshot.
590 */
591 function wppb_fb_flatten_repeater_subfields( $inner_blocks, $repeater_meta_name, $manage, $global_subfield_state = null ) {
592 $group = array(); // sub-field rows we'll write to the option
593 $group_ids = array(); // ids already used inside this group
594 $claimed_in_group = array(); // meta-names already claimed inside this group
595
596 // Global ID pool: manage_fields + every other Repeater's sub-fields, reusing
597 // the caller's snapshot when it threaded one.
598 $other_subfield_ids = wppb_fb_collect_global_subfield_ids( $manage, $global_subfield_state );
599
600 // Seed from stored row first or non-schema keys strip every save.
601 $existing_by_id = array();
602 // array() default, not the 'not_set' sentinel used at the write below: here
603 // "no option" and "empty option" seed the same empty index.
604 $existing_group = get_option( $repeater_meta_name, array() );
605 if ( is_array( $existing_group ) ) {
606 foreach ( $existing_group as $existing_row ) {
607 if ( is_array( $existing_row ) && isset( $existing_row['id'] ) ) {
608 $existing_by_id[ (int) $existing_row['id'] ] = $existing_row;
609 }
610 }
611 }
612 // Strip from seed too — patch filter is bypassed for the seed base.
613 $stripped_seed_attrs = (array) wppb_fb_repeater_subfield_stripped_attrs();
614
615 foreach ( $inner_blocks as $block ) {
616 if ( empty( $block['blockName'] ) ) continue;
617 if ( strpos( $block['blockName'], 'profile-builder/' ) !== 0 ) continue;
618
619 $lookup = WPPB_FB_Field_Registry::by_block_name( $block['blockName'] );
620 if ( ! $lookup ) continue;
621
622 $field_type = $lookup['field_type'];
623 // Repeaters cannot nest inside Repeaters in the legacy data model.
624 if ( $field_type === 'Repeater' ) continue;
625
626 $attrs = $block['attrs'];
627 $id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0;
628
629 $reallocated = false;
630 if ( $id === 0 || in_array( $id, $group_ids, true ) ) {
631 // Allocate a fresh ID across the global pool + already-allocated group ids.
632 $id = wppb_fb_next_field_id( $manage, array_merge( $other_subfield_ids, $group_ids ) );
633 $reallocated = true;
634 }
635 $group_ids[] = $id;
636
637 // Schema-hydrated patch, same shape as the top-level flatten. The helper
638 // drops the stripped attrs, so there is nothing to pre-filter here.
639 $patch = array();
640 foreach ( $lookup['attributes'] as $attr_name => $schema ) {
641 $patch[ $attr_name ] = isset( $attrs[ $attr_name ] )
642 ? $attrs[ $attr_name ]
643 : ( isset( $schema['default'] ) ? $schema['default'] : '' );
644 }
645 if ( isset( $attrs['meta-name'] ) ) {
646 $patch['meta-name'] = $attrs['meta-name'];
647 }
648
649 // A reallocated id is a brand-new sub-field with no stored counterpart,
650 // so it starts bare.
651 $base_row = ( ! $reallocated && isset( $existing_by_id[ $id ] ) )
652 ? $existing_by_id[ $id ]
653 : array();
654 foreach ( $stripped_seed_attrs as $strip_key ) {
655 unset( $base_row[ $strip_key ] );
656 }
657 $base_row['id'] = $id;
658 $base_row['field'] = $field_type;
659
660 $row = wppb_fb_apply_field_row_patch(
661 $base_row,
662 $patch,
663 array(
664 'field_type' => $field_type,
665 'manage' => $manage,
666 'claimed' => $claimed_in_group,
667 'is_subfield' => true,
668 'group' => $group,
669 'current_repeater_meta' => $repeater_meta_name,
670 'extra_known_metas' => array_keys( $claimed_in_group ),
671 'global_subfield_state' => $global_subfield_state,
672 )
673 );
674
675 $resolved_meta = isset( $row['meta-name'] ) ? (string) $row['meta-name'] : '';
676 if ( $resolved_meta !== '' ) {
677 $claimed_in_group[ $resolved_meta ] = true;
678 }
679
680 $group[] = $row;
681 }
682
683 // Rebuilding the group without an unrenderable sub-row would delete its
684 // definition permanently and orphan its `<name>_1`, `<name>_2`, … usermeta.
685 if ( is_array( $existing_group ) && ! empty( $existing_group ) ) {
686 $group = wppb_fb_carry_forward_unrenderable_subfields( $group, $existing_group );
687 }
688
689 // Skip update_option when identical (still fires hooks/cache even on no-op).
690 $existing = get_option( $repeater_meta_name, 'not_set' );
691 if ( is_array( $existing ) && $existing === $group ) return $group;
692
693 update_option( $repeater_meta_name, $group );
694
695 return $group;
696 }
697
698 // 4. Cascade delete: wppb_manage_fields row -> per-form ordered lists
699
700 /**
701 * Forms whose ordered list contains this global field id (delete-confirm warning).
702 *
703 * @param int $field_id Global wppb_manage_fields row id.
704 * @return array<int,array{id:int,title:string,post_type:string}>
705 */
706 function wppb_fb_forms_using_field( $field_id ) {
707 $field_id = (int) $field_id;
708 $found = array();
709
710 foreach ( array( 'rf', 'epf' ) as $form_type ) {
711 // Full posts: primes meta + title in bulk (ids-only would N+1).
712 $forms = get_posts( array(
713 'post_type' => 'wppb-' . $form_type . '-cpt',
714 'numberposts' => -1,
715 'posts_per_page' => -1,
716 'post_status' => 'any',
717 'no_found_rows' => true,
718 'update_post_term_cache' => false,
719 ) );
720
721 $meta_key = 'wppb_' . $form_type . '_fields';
722 foreach ( $forms as $form ) {
723 $form_id = (int) $form->ID;
724 $fields_in_form = get_post_meta( $form_id, $meta_key, true );
725 if ( ! is_array( $fields_in_form ) || empty( $fields_in_form ) ) continue;
726 foreach ( $fields_in_form as $field ) {
727 if ( isset( $field['id'] ) && (int) $field['id'] === $field_id ) {
728 $title = get_the_title( $form );
729 $found[] = array(
730 'id' => (int) $form_id,
731 'title' => ( $title !== '' )
732 ? $title
733 /* translators: %d: form post ID */
734 : sprintf( __( '(no title) #%d', 'profile-builder' ), (int) $form_id ),
735 'post_type' => 'wppb-' . $form_type . '-cpt',
736 );
737 break; // Count each form once, regardless of duplicate rows.
738 }
739 }
740 }
741 }
742
743 return $found;
744 }
745
746 /**
747 * Cascade a manage_fields delete into every form's ordered list (legacy listener
748 * is not loaded while the form-builder is active).
749 */
750 add_action( 'wck_before_remove_meta', 'wppb_fb_cascade_delete_field_from_forms', 10, 3 );
751 function wppb_fb_cascade_delete_field_from_forms( $meta, $id, $element_id ) {
752 if ( $meta !== 'wppb_manage_fields' ) return;
753
754 $all_fields = get_option( $meta );
755 if ( ! is_array( $all_fields ) || ! isset( $all_fields[ $element_id ]['id'] ) ) return;
756 $deleted_id = (int) $all_fields[ $element_id ]['id'];
757
758 foreach ( array( 'rf', 'epf' ) as $form_type ) {
759 $forms = get_posts( array(
760 'post_type' => 'wppb-' . $form_type . '-cpt',
761 'numberposts' => -1,
762 'posts_per_page' => -1,
763 'post_status' => 'any',
764 'fields' => 'ids',
765 'no_found_rows' => true,
766 'update_post_meta_cache' => false,
767 'update_post_term_cache' => false,
768 ) );
769 if ( ! empty( $forms ) ) update_meta_cache( 'post', $forms ); // one IN() query, not one per form
770
771 foreach ( $forms as $form_id ) {
772 $meta_key = 'wppb_' . $form_type . '_fields';
773 $fields_in_form = get_post_meta( $form_id, $meta_key, true );
774 if ( ! is_array( $fields_in_form ) || empty( $fields_in_form ) ) continue;
775 $changed = false;
776 foreach ( $fields_in_form as $key => $field ) {
777 if ( isset( $field['id'] ) && (int) $field['id'] === $deleted_id ) {
778 unset( $fields_in_form[ $key ] );
779 $changed = true;
780 }
781 }
782 if ( $changed ) {
783 update_post_meta( $form_id, $meta_key, array_values( $fields_in_form ) );
784 }
785 }
786 }
787 }
788