PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / trunk
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More vtrunk
4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-registry.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 2 weeks ago class-form-captcha-handler.php 1 week ago class-form-controller.php 2 weeks ago class-form-email-config-check.php 2 weeks ago class-form-email-handler.php 2 weeks ago class-form-encryption.php 2 weeks ago class-form-exporter.php 2 weeks ago class-form-field-validator.php 2 weeks ago class-form-file-handler.php 1 week ago class-form-google-auth.php 2 weeks ago class-form-integration-handler.php 2 weeks ago class-form-math-parser.php 2 weeks ago class-form-permissions.php 2 weeks ago class-form-registry.php 2 weeks ago class-form-settings.php 2 weeks ago class-form-submission-cpt.php 2 weeks ago class-form-submission-handler.php 2 weeks ago
class-form-registry.php
544 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 use SuperbAddons\Data\Utils\Engagement;
8
9 class FormRegistry
10 {
11 const OPTION_KEY = 'spb_form_registry';
12 const CONFIG_PREFIX = 'spb_form_cfg_';
13 const BLOCK_NAME = 'superb-addons/form';
14 const MULTISTEP_BLOCK_NAME = 'superb-addons/multistep-form';
15 const STEP_BLOCK_NAME = 'superb-addons/form-step';
16 const FIELD_BLOCK_NAME = 'superb-addons/form-field';
17
18 private static $supported_post_types = array('post', 'page', 'wp_template', 'wp_template_part', 'wp_block');
19
20 /**
21 * Get the list of post types that can contain form blocks.
22 */
23 public static function GetSupportedPostTypes()
24 {
25 return self::$supported_post_types;
26 }
27
28 public static function Initialize()
29 {
30 add_action('save_post', array(__CLASS__, 'OnSavePost'), 10, 2);
31 add_action('before_delete_post', array(__CLASS__, 'OnDeletePost'), 10, 2);
32 }
33
34 /**
35 * Hook: save_post — scan content for form blocks and update registry + configs.
36 */
37 public static function OnSavePost($post_id, $post)
38 {
39 // Bail on autosave, revisions, or unsupported post types
40 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
41 return;
42 }
43 if (wp_is_post_revision($post_id)) {
44 return;
45 }
46 if (!in_array($post->post_type, self::$supported_post_types, true)) {
47 return;
48 }
49
50 $registry = self::GetAll();
51 $changed = false;
52
53 // Find all form blocks in the saved content
54 $found_form_ids = array();
55 $parsed_blocks = parse_blocks($post->post_content);
56
57 foreach ($parsed_blocks as $block) {
58 self::CollectFormBlocks($block, $found_form_ids, $post_id, $post->post_type, $registry, $changed);
59 }
60
61 if (!empty($found_form_ids)) {
62 Engagement::MarkUsed(Engagement::FEATURE_FORM);
63 }
64
65 // Cleanup: remove forms that were previously on this post but are no longer
66 $forms_with_submissions = null; // lazy-loaded
67 foreach ($registry as $fid => $data) {
68 if (isset($data['source_post_id']) && intval($data['source_post_id']) === intval($post_id) && !in_array($fid, $found_form_ids, true)) {
69 // Form was on this post but no longer — check for submissions before removing
70 if ($forms_with_submissions === null) {
71 $forms_with_submissions = FormSubmissionHandler::GetDistinctFormIds();
72 }
73 if (in_array($fid, $forms_with_submissions, true)) {
74 // Has submissions: flag for deletion once submissions are cleared
75 $registry[$fid]['source_post_id'] = null;
76 $registry[$fid]['source_post_type'] = null;
77 $registry[$fid]['pending_delete'] = true;
78 $registry[$fid]['updated'] = time();
79 } else {
80 unset($registry[$fid]);
81 }
82 $changed = true;
83 // Delete the stored config for removed forms
84 delete_option(self::CONFIG_PREFIX . sanitize_key($fid));
85 }
86 }
87
88 if ($changed) {
89 update_option(self::OPTION_KEY, $registry, false);
90 }
91 }
92
93 /**
94 * Recursively collect form blocks, update registry entries, and store configs.
95 */
96 private static function CollectFormBlocks($block, &$found_form_ids, $post_id, $post_type, &$registry, &$changed)
97 {
98 $is_form = isset($block['blockName']) && $block['blockName'] === self::BLOCK_NAME && !empty($block['attrs']['formId']);
99 $is_multistep = isset($block['blockName']) && $block['blockName'] === self::MULTISTEP_BLOCK_NAME && !empty($block['attrs']['formId']);
100
101 if ($is_form || $is_multistep) {
102 $form_id = sanitize_key($block['attrs']['formId']);
103 $form_name = isset($block['attrs']['formName']) ? sanitize_text_field($block['attrs']['formName']) : '';
104 $found_form_ids[] = $form_id;
105
106 $entry = isset($registry[$form_id]) ? $registry[$form_id] : null;
107 if (
108 !$entry
109 || $entry['name'] !== $form_name
110 || $entry['source_post_id'] !== $post_id
111 || $entry['source_post_type'] !== $post_type
112 || !empty($entry['pending_delete'])
113 ) {
114 $registry[$form_id] = array(
115 'name' => $form_name,
116 'source_post_id' => $post_id,
117 'source_post_type' => $post_type,
118 'updated' => time(),
119 );
120 $changed = true;
121 }
122
123 // Store form config (attributes + inner field blocks)
124 $attrs = isset($block['attrs']) ? $block['attrs'] : array();
125 $form_fields = self::ExtractFieldBlocks($block, $is_multistep);
126 $attrs['formFields'] = $form_fields;
127
128 // Extract webhook secret to encrypted storage and remove from config
129 if (isset($attrs['webhookSecret']) && $attrs['webhookSecret'] !== '') {
130 FormSettings::SetWebhookSecret($form_id, $attrs['webhookSecret']);
131 }
132 unset($attrs['webhookSecret']);
133
134 update_option(self::CONFIG_PREFIX . $form_id, $attrs, false);
135 }
136
137 // Recurse into inner blocks
138 if (!empty($block['innerBlocks'])) {
139 foreach ($block['innerBlocks'] as $inner) {
140 self::CollectFormBlocks($inner, $found_form_ids, $post_id, $post_type, $registry, $changed);
141 }
142 }
143 }
144
145 /**
146 * Extract form-field blocks from a form or multistep-form block.
147 * For multistep forms, recurses through form-step inner blocks.
148 */
149 private static function ExtractFieldBlocks($block, $is_multistep = false)
150 {
151 $form_fields = array();
152 if (empty($block['innerBlocks'])) {
153 return $form_fields;
154 }
155
156 foreach ($block['innerBlocks'] as $inner) {
157 if ($is_multistep && isset($inner['blockName']) && $inner['blockName'] === self::STEP_BLOCK_NAME) {
158 // Recurse into form-step to find form-field blocks
159 if (!empty($inner['innerBlocks'])) {
160 foreach ($inner['innerBlocks'] as $step_inner) {
161 if (self::IsConfigField($step_inner)) {
162 $form_fields[] = $step_inner['attrs'];
163 }
164 }
165 }
166 } elseif (self::IsConfigField($inner)) {
167 $form_fields[] = $inner['attrs'];
168 }
169 }
170
171 return $form_fields;
172 }
173
174 /**
175 * Whether an inner block is a form-field that belongs in the form config.
176 * Content fields are static text — they render no input and submit no value,
177 * so validation, emails, submissions, exports, and integrations must never
178 * see them. Excluding them here covers all of those in one place.
179 */
180 private static function IsConfigField($block)
181 {
182 if (!isset($block['blockName']) || $block['blockName'] !== self::FIELD_BLOCK_NAME) {
183 return false;
184 }
185 if (empty($block['attrs']['fieldId'])) {
186 return false;
187 }
188 $type = isset($block['attrs']['fieldType']) ? $block['attrs']['fieldType'] : 'text';
189 return $type !== 'content';
190 }
191
192 /**
193 * Get the full registry array.
194 */
195 public static function GetAll()
196 {
197 $registry = get_option(self::OPTION_KEY, array());
198 return is_array($registry) ? $registry : array();
199 }
200
201 /**
202 * Get a single registry entry.
203 */
204 public static function Get($form_id)
205 {
206 $registry = self::GetAll();
207 return isset($registry[$form_id]) ? $registry[$form_id] : null;
208 }
209
210 /**
211 * Check if a form is flagged as pending deletion.
212 */
213 public static function IsPendingDelete($form_id)
214 {
215 $entry = self::Get($form_id);
216 return $entry && !empty($entry['pending_delete']);
217 }
218
219 /**
220 * Get display name for a form, with fallback.
221 */
222 public static function GetName($form_id)
223 {
224 $entry = self::Get($form_id);
225 if ($entry && !empty($entry['name'])) {
226 return $entry['name'];
227 }
228 /* translators: %s: form ID */
229 return sprintf(__('Unnamed Form (%s)', 'superb-blocks'), $form_id);
230 }
231
232 /**
233 * Remove a form from the registry.
234 */
235 public static function Remove($form_id)
236 {
237 $registry = self::GetAll();
238 if (isset($registry[$form_id])) {
239 unset($registry[$form_id]);
240 update_option(self::OPTION_KEY, $registry, false);
241 }
242 }
243
244 /**
245 * Remove a form block from its source post content.
246 * Returns true if the block was found and removed, false otherwise.
247 */
248 public static function RemoveFormBlock($form_id)
249 {
250 $entry = self::Get($form_id);
251 if (!$entry || empty($entry['source_post_id'])) {
252 return false;
253 }
254
255 $post = get_post($entry['source_post_id']);
256 if (!$post || empty($post->post_content)) {
257 return false;
258 }
259
260 $blocks = parse_blocks($post->post_content);
261 $filtered = self::FilterOutFormBlock($form_id, $blocks);
262
263 if ($filtered === null) {
264 return false;
265 }
266
267 $new_content = serialize_blocks($filtered);
268
269 // Use wp_update_post to trigger save_post hooks (which will update the registry)
270 return wp_update_post(array(
271 'ID' => $post->ID,
272 'post_content' => $new_content,
273 )) !== 0;
274 }
275
276 /**
277 * Recursively filter out a form block with a specific formId.
278 * Returns the filtered blocks array, or null if the block was not found.
279 */
280 private static function FilterOutFormBlock($form_id, $blocks)
281 {
282 $found = false;
283 $result = array();
284
285 foreach ($blocks as $block) {
286 if (
287 isset($block['blockName']) && ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
288 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id
289 ) {
290 $found = true;
291 continue;
292 }
293
294 // Recurse into inner blocks
295 if (!empty($block['innerBlocks'])) {
296 $inner_result = self::FilterOutFormBlock($form_id, $block['innerBlocks']);
297 if ($inner_result !== null) {
298 $found = true;
299 $block['innerBlocks'] = $inner_result;
300 }
301 }
302
303 $result[] = $block;
304 }
305
306 return $found ? $result : null;
307 }
308
309 /**
310 * Hook: before_delete_post — handles permanent post deletion (trash emptying).
311 * save_post does NOT fire when a trashed post is permanently deleted.
312 */
313 public static function OnDeletePost($post_id, $post)
314 {
315 if (!in_array($post->post_type, self::$supported_post_types, true)) {
316 return;
317 }
318
319 $registry = self::GetAll();
320 $changed = false;
321 $forms_with_submissions = null;
322
323 foreach ($registry as $fid => $data) {
324 if (isset($data['source_post_id']) && intval($data['source_post_id']) === intval($post_id)) {
325 if ($forms_with_submissions === null) {
326 $forms_with_submissions = FormSubmissionHandler::GetDistinctFormIds();
327 }
328 if (in_array($fid, $forms_with_submissions, true)) {
329 $registry[$fid]['source_post_id'] = null;
330 $registry[$fid]['source_post_type'] = null;
331 $registry[$fid]['pending_delete'] = true;
332 $registry[$fid]['updated'] = time();
333 } else {
334 unset($registry[$fid]);
335 delete_option(self::CONFIG_PREFIX . sanitize_key($fid));
336 }
337 $changed = true;
338 }
339 }
340
341 if ($changed) {
342 update_option(self::OPTION_KEY, $registry, false);
343 }
344 }
345
346 /**
347 * Clean up registry entries flagged for deletion once their submissions are gone.
348 * Call this after deleting submissions for a specific form.
349 */
350 public static function CleanupAfterSubmissionDelete($form_id)
351 {
352 $registry = self::GetAll();
353 $entry = isset($registry[$form_id]) ? $registry[$form_id] : null;
354
355 if (!$entry || empty($entry['pending_delete'])) {
356 return;
357 }
358
359 // Check if this form still has any submissions
360 $count = FormSubmissionHandler::GetCount($form_id);
361 if (intval($count['total']) === 0) {
362 unset($registry[$form_id]);
363 update_option(self::OPTION_KEY, $registry, false);
364 delete_option(self::CONFIG_PREFIX . sanitize_key($form_id));
365 }
366 }
367
368 /**
369 * Get stored form config, rebuilding from source post if missing.
370 * Returns the config array or null if form cannot be found.
371 */
372 public static function GetConfig($form_id)
373 {
374 $config = get_option(self::CONFIG_PREFIX . sanitize_key($form_id));
375 if (!empty($config) && is_array($config)) {
376 return $config;
377 }
378
379 return self::RebuildConfig($form_id);
380 }
381
382 /**
383 * Rebuild the form config from source post content and store as option.
384 * Returns the config array or null if form cannot be found.
385 */
386 public static function RebuildConfig($form_id)
387 {
388 $entry = self::Get($form_id);
389 $source_post_id = ($entry && isset($entry['source_post_id'])) ? $entry['source_post_id'] : null;
390
391 $post = null;
392 if ($source_post_id !== null) {
393 $post = get_post($source_post_id);
394 }
395
396 // If source post is gone or unknown, try a broad search
397 if (!$post || !self::PostContainsForm($form_id, $post)) {
398 $found = self::FindFormInPosts($form_id);
399 if (!$found) {
400 return null;
401 }
402 $post = get_post($found['post_id']);
403 if (!$post) {
404 return null;
405 }
406 // Update registry with discovered source
407 $registry = self::GetAll();
408 if (isset($registry[$form_id])) {
409 $registry[$form_id]['source_post_id'] = $found['post_id'];
410 $registry[$form_id]['source_post_type'] = $found['post_type'];
411 $registry[$form_id]['updated'] = time();
412 update_option(self::OPTION_KEY, $registry, false);
413 }
414 }
415
416 $form_data = self::ExtractFormFromPost($form_id, $post);
417 if (!$form_data) {
418 return null;
419 }
420
421 update_option(self::CONFIG_PREFIX . sanitize_key($form_id), $form_data, false);
422
423 return $form_data;
424 }
425
426 /**
427 * Check if a post contains a form block with a specific formId.
428 */
429 private static function PostContainsForm($form_id, $post)
430 {
431 if (!has_block(self::BLOCK_NAME, $post) && !has_block(self::MULTISTEP_BLOCK_NAME, $post)) {
432 return false;
433 }
434
435 $parsed_blocks = parse_blocks($post->post_content);
436 $flattened = function_exists('_flatten_blocks') ? _flatten_blocks($parsed_blocks) : self::FlattenBlocks($parsed_blocks);
437
438 foreach ($flattened as $block) {
439 $is_match = ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
440 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id;
441 if ($is_match) {
442 return true;
443 }
444 }
445
446 return false;
447 }
448
449 /**
450 * Search across post types for a form block with a specific formId.
451 * Returns array('post_id' => int, 'post_type' => string) or null.
452 */
453 private static function FindFormInPosts($form_id)
454 {
455 $posts = get_posts(array(
456 'post_type' => self::$supported_post_types,
457 'post_status' => array('publish', 'draft', 'private', 'pending', 'future'),
458 'posts_per_page' => -1,
459 'fields' => 'ids',
460 'no_found_rows' => true,
461 'update_post_meta_cache' => false,
462 'update_post_term_cache' => false,
463 ));
464
465 foreach ($posts as $pid) {
466 $post = get_post($pid);
467 if (!$post || empty($post->post_content)) {
468 continue;
469 }
470 // Fast check: does the content even mention our block?
471 if (!has_block(self::BLOCK_NAME, $post) && !has_block(self::MULTISTEP_BLOCK_NAME, $post)) {
472 continue;
473 }
474 if (self::PostContainsForm($form_id, $post)) {
475 return array(
476 'post_id' => $post->ID,
477 'post_type' => $post->post_type,
478 );
479 }
480 }
481
482 return null;
483 }
484
485 /**
486 * Extract a form block's full attributes (including inner field blocks) from a post.
487 * Returns the attributes array or null if not found.
488 */
489 private static function ExtractFormFromPost($form_id, $post)
490 {
491 $parsed_blocks = parse_blocks($post->post_content);
492 $form_block = self::FindFormBlock($form_id, $parsed_blocks);
493
494 if (!$form_block) {
495 return null;
496 }
497
498 $attrs = isset($form_block['attrs']) ? $form_block['attrs'] : array();
499
500 // Extract inner form-field blocks (handles both flat and multistep nesting)
501 $is_multistep = isset($form_block['blockName']) && $form_block['blockName'] === self::MULTISTEP_BLOCK_NAME;
502 $attrs['formFields'] = self::ExtractFieldBlocks($form_block, $is_multistep);
503
504 return $attrs;
505 }
506
507 /**
508 * Recursively find a form block with a specific formId in parsed blocks.
509 */
510 private static function FindFormBlock($form_id, $blocks)
511 {
512 foreach ($blocks as $block) {
513 if (
514 isset($block['blockName']) && ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
515 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id
516 ) {
517 return $block;
518 }
519 if (!empty($block['innerBlocks'])) {
520 $found = self::FindFormBlock($form_id, $block['innerBlocks']);
521 if ($found) {
522 return $found;
523 }
524 }
525 }
526 return null;
527 }
528
529 /**
530 * Fallback flatten for WP versions without _flatten_blocks().
531 */
532 private static function FlattenBlocks($blocks)
533 {
534 $result = array();
535 foreach ($blocks as $block) {
536 $result[] = $block;
537 if (!empty($block['innerBlocks'])) {
538 $result = array_merge($result, self::FlattenBlocks($block['innerBlocks']));
539 }
540 }
541 return $result;
542 }
543 }
544