PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.7
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.7
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 1 week ago class-form-captcha-handler.php 1 week ago class-form-controller.php 1 week ago class-form-email-config-check.php 1 week ago class-form-email-handler.php 1 week ago class-form-encryption.php 1 week ago class-form-exporter.php 1 week ago class-form-field-validator.php 1 week ago class-form-file-handler.php 1 week ago class-form-google-auth.php 1 week ago class-form-integration-handler.php 1 week ago class-form-math-parser.php 1 week ago class-form-permissions.php 1 week ago class-form-registry.php 1 week ago class-form-settings.php 1 week ago class-form-submission-cpt.php 1 week ago class-form-submission-handler.php 1 week ago
class-form-registry.php
532 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 (
162 isset($step_inner['blockName']) && $step_inner['blockName'] === self::FIELD_BLOCK_NAME
163 && !empty($step_inner['attrs']['fieldId'])
164 ) {
165 $form_fields[] = $step_inner['attrs'];
166 }
167 }
168 }
169 } elseif (
170 isset($inner['blockName']) && $inner['blockName'] === self::FIELD_BLOCK_NAME
171 && !empty($inner['attrs']['fieldId'])
172 ) {
173 $form_fields[] = $inner['attrs'];
174 }
175 }
176
177 return $form_fields;
178 }
179
180 /**
181 * Get the full registry array.
182 */
183 public static function GetAll()
184 {
185 $registry = get_option(self::OPTION_KEY, array());
186 return is_array($registry) ? $registry : array();
187 }
188
189 /**
190 * Get a single registry entry.
191 */
192 public static function Get($form_id)
193 {
194 $registry = self::GetAll();
195 return isset($registry[$form_id]) ? $registry[$form_id] : null;
196 }
197
198 /**
199 * Check if a form is flagged as pending deletion.
200 */
201 public static function IsPendingDelete($form_id)
202 {
203 $entry = self::Get($form_id);
204 return $entry && !empty($entry['pending_delete']);
205 }
206
207 /**
208 * Get display name for a form, with fallback.
209 */
210 public static function GetName($form_id)
211 {
212 $entry = self::Get($form_id);
213 if ($entry && !empty($entry['name'])) {
214 return $entry['name'];
215 }
216 /* translators: %s: form ID */
217 return sprintf(__('Unnamed Form (%s)', 'superb-blocks'), $form_id);
218 }
219
220 /**
221 * Remove a form from the registry.
222 */
223 public static function Remove($form_id)
224 {
225 $registry = self::GetAll();
226 if (isset($registry[$form_id])) {
227 unset($registry[$form_id]);
228 update_option(self::OPTION_KEY, $registry, false);
229 }
230 }
231
232 /**
233 * Remove a form block from its source post content.
234 * Returns true if the block was found and removed, false otherwise.
235 */
236 public static function RemoveFormBlock($form_id)
237 {
238 $entry = self::Get($form_id);
239 if (!$entry || empty($entry['source_post_id'])) {
240 return false;
241 }
242
243 $post = get_post($entry['source_post_id']);
244 if (!$post || empty($post->post_content)) {
245 return false;
246 }
247
248 $blocks = parse_blocks($post->post_content);
249 $filtered = self::FilterOutFormBlock($form_id, $blocks);
250
251 if ($filtered === null) {
252 return false;
253 }
254
255 $new_content = serialize_blocks($filtered);
256
257 // Use wp_update_post to trigger save_post hooks (which will update the registry)
258 return wp_update_post(array(
259 'ID' => $post->ID,
260 'post_content' => $new_content,
261 )) !== 0;
262 }
263
264 /**
265 * Recursively filter out a form block with a specific formId.
266 * Returns the filtered blocks array, or null if the block was not found.
267 */
268 private static function FilterOutFormBlock($form_id, $blocks)
269 {
270 $found = false;
271 $result = array();
272
273 foreach ($blocks as $block) {
274 if (
275 isset($block['blockName']) && ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
276 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id
277 ) {
278 $found = true;
279 continue;
280 }
281
282 // Recurse into inner blocks
283 if (!empty($block['innerBlocks'])) {
284 $inner_result = self::FilterOutFormBlock($form_id, $block['innerBlocks']);
285 if ($inner_result !== null) {
286 $found = true;
287 $block['innerBlocks'] = $inner_result;
288 }
289 }
290
291 $result[] = $block;
292 }
293
294 return $found ? $result : null;
295 }
296
297 /**
298 * Hook: before_delete_post — handles permanent post deletion (trash emptying).
299 * save_post does NOT fire when a trashed post is permanently deleted.
300 */
301 public static function OnDeletePost($post_id, $post)
302 {
303 if (!in_array($post->post_type, self::$supported_post_types, true)) {
304 return;
305 }
306
307 $registry = self::GetAll();
308 $changed = false;
309 $forms_with_submissions = null;
310
311 foreach ($registry as $fid => $data) {
312 if (isset($data['source_post_id']) && intval($data['source_post_id']) === intval($post_id)) {
313 if ($forms_with_submissions === null) {
314 $forms_with_submissions = FormSubmissionHandler::GetDistinctFormIds();
315 }
316 if (in_array($fid, $forms_with_submissions, true)) {
317 $registry[$fid]['source_post_id'] = null;
318 $registry[$fid]['source_post_type'] = null;
319 $registry[$fid]['pending_delete'] = true;
320 $registry[$fid]['updated'] = time();
321 } else {
322 unset($registry[$fid]);
323 delete_option(self::CONFIG_PREFIX . sanitize_key($fid));
324 }
325 $changed = true;
326 }
327 }
328
329 if ($changed) {
330 update_option(self::OPTION_KEY, $registry, false);
331 }
332 }
333
334 /**
335 * Clean up registry entries flagged for deletion once their submissions are gone.
336 * Call this after deleting submissions for a specific form.
337 */
338 public static function CleanupAfterSubmissionDelete($form_id)
339 {
340 $registry = self::GetAll();
341 $entry = isset($registry[$form_id]) ? $registry[$form_id] : null;
342
343 if (!$entry || empty($entry['pending_delete'])) {
344 return;
345 }
346
347 // Check if this form still has any submissions
348 $count = FormSubmissionHandler::GetCount($form_id);
349 if (intval($count['total']) === 0) {
350 unset($registry[$form_id]);
351 update_option(self::OPTION_KEY, $registry, false);
352 delete_option(self::CONFIG_PREFIX . sanitize_key($form_id));
353 }
354 }
355
356 /**
357 * Get stored form config, rebuilding from source post if missing.
358 * Returns the config array or null if form cannot be found.
359 */
360 public static function GetConfig($form_id)
361 {
362 $config = get_option(self::CONFIG_PREFIX . sanitize_key($form_id));
363 if (!empty($config) && is_array($config)) {
364 return $config;
365 }
366
367 return self::RebuildConfig($form_id);
368 }
369
370 /**
371 * Rebuild the form config from source post content and store as option.
372 * Returns the config array or null if form cannot be found.
373 */
374 public static function RebuildConfig($form_id)
375 {
376 $entry = self::Get($form_id);
377 $source_post_id = ($entry && isset($entry['source_post_id'])) ? $entry['source_post_id'] : null;
378
379 $post = null;
380 if ($source_post_id !== null) {
381 $post = get_post($source_post_id);
382 }
383
384 // If source post is gone or unknown, try a broad search
385 if (!$post || !self::PostContainsForm($form_id, $post)) {
386 $found = self::FindFormInPosts($form_id);
387 if (!$found) {
388 return null;
389 }
390 $post = get_post($found['post_id']);
391 if (!$post) {
392 return null;
393 }
394 // Update registry with discovered source
395 $registry = self::GetAll();
396 if (isset($registry[$form_id])) {
397 $registry[$form_id]['source_post_id'] = $found['post_id'];
398 $registry[$form_id]['source_post_type'] = $found['post_type'];
399 $registry[$form_id]['updated'] = time();
400 update_option(self::OPTION_KEY, $registry, false);
401 }
402 }
403
404 $form_data = self::ExtractFormFromPost($form_id, $post);
405 if (!$form_data) {
406 return null;
407 }
408
409 update_option(self::CONFIG_PREFIX . sanitize_key($form_id), $form_data, false);
410
411 return $form_data;
412 }
413
414 /**
415 * Check if a post contains a form block with a specific formId.
416 */
417 private static function PostContainsForm($form_id, $post)
418 {
419 if (!has_block(self::BLOCK_NAME, $post) && !has_block(self::MULTISTEP_BLOCK_NAME, $post)) {
420 return false;
421 }
422
423 $parsed_blocks = parse_blocks($post->post_content);
424 $flattened = function_exists('_flatten_blocks') ? _flatten_blocks($parsed_blocks) : self::FlattenBlocks($parsed_blocks);
425
426 foreach ($flattened as $block) {
427 $is_match = ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
428 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id;
429 if ($is_match) {
430 return true;
431 }
432 }
433
434 return false;
435 }
436
437 /**
438 * Search across post types for a form block with a specific formId.
439 * Returns array('post_id' => int, 'post_type' => string) or null.
440 */
441 private static function FindFormInPosts($form_id)
442 {
443 $posts = get_posts(array(
444 'post_type' => self::$supported_post_types,
445 'post_status' => array('publish', 'draft', 'private', 'pending', 'future'),
446 'posts_per_page' => -1,
447 'fields' => 'ids',
448 'no_found_rows' => true,
449 'update_post_meta_cache' => false,
450 'update_post_term_cache' => false,
451 ));
452
453 foreach ($posts as $pid) {
454 $post = get_post($pid);
455 if (!$post || empty($post->post_content)) {
456 continue;
457 }
458 // Fast check: does the content even mention our block?
459 if (!has_block(self::BLOCK_NAME, $post) && !has_block(self::MULTISTEP_BLOCK_NAME, $post)) {
460 continue;
461 }
462 if (self::PostContainsForm($form_id, $post)) {
463 return array(
464 'post_id' => $post->ID,
465 'post_type' => $post->post_type,
466 );
467 }
468 }
469
470 return null;
471 }
472
473 /**
474 * Extract a form block's full attributes (including inner field blocks) from a post.
475 * Returns the attributes array or null if not found.
476 */
477 private static function ExtractFormFromPost($form_id, $post)
478 {
479 $parsed_blocks = parse_blocks($post->post_content);
480 $form_block = self::FindFormBlock($form_id, $parsed_blocks);
481
482 if (!$form_block) {
483 return null;
484 }
485
486 $attrs = isset($form_block['attrs']) ? $form_block['attrs'] : array();
487
488 // Extract inner form-field blocks (handles both flat and multistep nesting)
489 $is_multistep = isset($form_block['blockName']) && $form_block['blockName'] === self::MULTISTEP_BLOCK_NAME;
490 $attrs['formFields'] = self::ExtractFieldBlocks($form_block, $is_multistep);
491
492 return $attrs;
493 }
494
495 /**
496 * Recursively find a form block with a specific formId in parsed blocks.
497 */
498 private static function FindFormBlock($form_id, $blocks)
499 {
500 foreach ($blocks as $block) {
501 if (
502 isset($block['blockName']) && ($block['blockName'] === self::BLOCK_NAME || $block['blockName'] === self::MULTISTEP_BLOCK_NAME)
503 && isset($block['attrs']['formId']) && sanitize_key($block['attrs']['formId']) === $form_id
504 ) {
505 return $block;
506 }
507 if (!empty($block['innerBlocks'])) {
508 $found = self::FindFormBlock($form_id, $block['innerBlocks']);
509 if ($found) {
510 return $found;
511 }
512 }
513 }
514 return null;
515 }
516
517 /**
518 * Fallback flatten for WP versions without _flatten_blocks().
519 */
520 private static function FlattenBlocks($blocks)
521 {
522 $result = array();
523 foreach ($blocks as $block) {
524 $result[] = $block;
525 if (!empty($block['innerBlocks'])) {
526 $result = array_merge($result, self::FlattenBlocks($block['innerBlocks']));
527 }
528 }
529 return $result;
530 }
531 }
532