PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / abilities / class-forms-abilities.php

class-forms-abilities.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-forms/src/abilities/class-forms-abilities.php

948 lines 30.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack Forms Abilities Registration
4 *
5 * Registers Jetpack Forms abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack-forms
8 * @since 1.0.0
9 */
10
11 namespace Automattic\Jetpack\Forms\Abilities;
12
13 use Automattic\Jetpack\WP_Abilities\Registrar;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit( 0 );
17 }
18
19 /**
20 * Class Forms_Abilities
21 *
22 * Registers Jetpack Forms abilities with the WordPress Abilities API.
23 * Ability callbacks delegate to REST endpoints via rest_do_request()
24 * so they inherit endpoint validation, sanitization, and hooks.
25 */
26 class Forms_Abilities extends Registrar {
27
28 const CATEGORY_SLUG = 'jetpack-forms';
29
30 /**
31 * Form post statuses accepted by list-forms.
32 */
33 const FORM_STATUSES = array( 'publish', 'draft', 'trash' );
34
35 /**
36 * Form post statuses accepted by create-form.
37 */
38 const CREATE_FORM_STATUSES = array( 'publish', 'draft' );
39
40 /**
41 * Response post statuses accepted by get-responses / update-response.
42 */
43 const RESPONSE_STATUSES = array( 'publish', 'draft', 'spam', 'trash' );
44
45 /**
46 * Bulk actions accepted by bulk-update-responses.
47 */
48 const BULK_ACTIONS = array( 'mark_as_spam', 'mark_as_not_spam' );
49
50 /**
51 * Default block content used when create-form is called without `content`.
52 */
53 const DEFAULT_FORM_CONTENT = '<!-- wp:jetpack/contact-form --><!-- wp:jetpack/button {"element":"button","text":"Submit","lock":{"remove":true}} /--><!-- /wp:jetpack/contact-form -->';
54
55 /**
56 * Register the category and abilities.
57 *
58 * Forms abilities shipped (see #45998) before the
59 * `jetpack_wp_abilities_enabled` rollout filter existed, so this
60 * deliberately bypasses that gate — backing the `get-responses`,
61 * `update-response`, and `get-status-counts` abilities out behind a
62 * default-off filter would silently break consumers that already
63 * dispatch them. The newer admin abilities ride along for parity:
64 * Forms abilities are uniformly available wherever the package is
65 * loaded.
66 *
67 * @return void
68 */
69 public static function init() {
70 if ( did_action( self::CATEGORIES_INIT_ACTION ) ) {
71 static::register_category();
72 } else {
73 add_action( self::CATEGORIES_INIT_ACTION, array( static::class, 'register_category' ) );
74 }
75
76 if ( did_action( self::ABILITIES_INIT_ACTION ) ) {
77 static::register_abilities();
78 } else {
79 add_action( self::ABILITIES_INIT_ACTION, array( static::class, 'register_abilities' ) );
80 }
81 }
82
83 /**
84 * {@inheritDoc}
85 */
86 public static function get_category_slug(): string {
87 return self::CATEGORY_SLUG;
88 }
89
90 /**
91 * {@inheritDoc}
92 */
93 public static function get_category_definition(): array {
94 return array(
95 // "Jetpack Forms" is a product name and should not be translated.
96 'label' => 'Jetpack Forms',
97 'description' => __( 'Abilities for managing Jetpack Forms and their responses.', 'jetpack-forms' ),
98 );
99 }
100
101 /**
102 * {@inheritDoc}
103 */
104 public static function get_abilities(): array {
105 return array(
106 'jetpack-forms/list-forms' => self::spec_list_forms(),
107 'jetpack-forms/get-form' => self::spec_get_form(),
108 'jetpack-forms/create-form' => self::spec_create_form(),
109 'jetpack-forms/delete-form' => self::spec_delete_form(),
110 'jetpack-forms/get-responses' => self::spec_get_responses(),
111 'jetpack-forms/update-response' => self::spec_update_response(),
112 'jetpack-forms/bulk-update-responses' => self::spec_bulk_update_responses(),
113 'jetpack-forms/get-status-counts' => self::spec_get_status_counts(),
114 );
115 }
116
117 /*
118 ---------------------------------------------------------------------
119 * Ability specs
120 * ---------------------------------------------------------------------
121 */
122
123 /**
124 * Spec: jetpack-forms/list-forms.
125 */
126 private static function spec_list_forms(): array {
127 return array(
128 'label' => __( 'List forms (admin)', 'jetpack-forms' ),
129 'description' => __( 'List all forms with admin detail including response counts, status, and edit URLs. Supports pagination, search, and status filtering.', 'jetpack-forms' ),
130 'input_schema' => array(
131 'type' => 'object',
132 'default' => array(),
133 'properties' => array(
134 'page' => array(
135 'type' => 'integer',
136 'description' => __( 'Page number for paginated results.', 'jetpack-forms' ),
137 'default' => 1,
138 'minimum' => 1,
139 ),
140 'per_page' => array(
141 'type' => 'integer',
142 'description' => __( 'Number of forms per page.', 'jetpack-forms' ),
143 'default' => 10,
144 'minimum' => 1,
145 'maximum' => 100,
146 ),
147 'search' => array(
148 'type' => 'string',
149 'description' => __( 'Search forms by title.', 'jetpack-forms' ),
150 ),
151 'status' => array(
152 'type' => 'string',
153 'description' => __( 'Filter by form status.', 'jetpack-forms' ),
154 'enum' => self::FORM_STATUSES,
155 ),
156 ),
157 'additionalProperties' => false,
158 ),
159 'execute_callback' => array( __CLASS__, 'list_forms' ),
160 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
161 'meta' => array(
162 'annotations' => array(
163 'readonly' => true,
164 'destructive' => false,
165 'idempotent' => true,
166 ),
167 'show_in_rest' => true,
168 'mcp' => array(
169 'public' => true,
170 'type' => 'tool', // default is already "tool", but can be explicit.
171 ),
172 ),
173 );
174 }
175
176 /**
177 * Spec: jetpack-forms/get-form.
178 */
179 private static function spec_get_form(): array {
180 return array(
181 'label' => __( 'Get form details', 'jetpack-forms' ),
182 'description' => __( 'Get a single form with its full structure including field definitions, status, and edit URL.', 'jetpack-forms' ),
183 'input_schema' => array(
184 'type' => 'object',
185 'required' => array( 'id' ),
186 'properties' => array(
187 'id' => array(
188 'type' => 'integer',
189 'description' => __( 'The form ID.', 'jetpack-forms' ),
190 ),
191 ),
192 'additionalProperties' => false,
193 ),
194 'execute_callback' => array( __CLASS__, 'get_form' ),
195 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
196 'meta' => array(
197 'annotations' => array(
198 'readonly' => true,
199 'destructive' => false,
200 'idempotent' => true,
201 ),
202 'show_in_rest' => true,
203 'mcp' => array(
204 'public' => true,
205 'type' => 'tool', // default is already "tool", but can be explicit.
206 ),
207 ),
208 );
209 }
210
211 /**
212 * Spec: jetpack-forms/create-form.
213 */
214 private static function spec_create_form(): array {
215 return array(
216 'label' => __( 'Create a form', 'jetpack-forms' ),
217 'description' => __( 'Create a new form with a title. Optionally provide block content for the form structure. Returns the new form ID and edit URL.', 'jetpack-forms' ),
218 'input_schema' => array(
219 'type' => 'object',
220 'required' => array( 'title' ),
221 'properties' => array(
222 'title' => array(
223 'type' => 'string',
224 'description' => __( 'The form title/name.', 'jetpack-forms' ),
225 ),
226 'content' => array(
227 'type' => 'string',
228 'description' => __( 'Block content for the form structure. If omitted, creates an empty form with a submit button.', 'jetpack-forms' ),
229 ),
230 'status' => array(
231 'type' => 'string',
232 'description' => __( 'Initial form status.', 'jetpack-forms' ),
233 'enum' => self::CREATE_FORM_STATUSES,
234 'default' => 'publish',
235 ),
236 ),
237 'additionalProperties' => false,
238 ),
239 'execute_callback' => array( __CLASS__, 'create_form' ),
240 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
241 'meta' => array(
242 'annotations' => array(
243 'readonly' => false,
244 'destructive' => false,
245 'idempotent' => false,
246 ),
247 'show_in_rest' => true,
248 'mcp' => array(
249 'public' => true,
250 'type' => 'tool', // default is already "tool", but can be explicit.
251 ),
252 ),
253 );
254 }
255
256 /**
257 * Spec: jetpack-forms/delete-form.
258 */
259 private static function spec_delete_form(): array {
260 return array(
261 'label' => __( 'Delete a form', 'jetpack-forms' ),
262 'description' => __( 'Move a form to the trash. Does not permanently delete. Trashed forms can be restored.', 'jetpack-forms' ),
263 'input_schema' => array(
264 'type' => 'object',
265 'required' => array( 'id' ),
266 'properties' => array(
267 'id' => array(
268 'type' => 'integer',
269 'description' => __( 'The form ID to delete.', 'jetpack-forms' ),
270 ),
271 ),
272 'additionalProperties' => false,
273 ),
274 'execute_callback' => array( __CLASS__, 'delete_form' ),
275 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
276 'meta' => array(
277 'annotations' => array(
278 'readonly' => false,
279 'destructive' => true,
280 'idempotent' => true,
281 ),
282 'show_in_rest' => true,
283 'mcp' => array(
284 'public' => true,
285 'type' => 'tool', // default is already "tool", but can be explicit.
286 ),
287 ),
288 );
289 }
290
291 /**
292 * Spec: jetpack-forms/get-responses.
293 */
294 private static function spec_get_responses(): array {
295 return array(
296 'label' => __( 'Get form responses', 'jetpack-forms' ),
297 'description' => __( 'List or search form responses. Returns response data including sender info, form fields, and metadata. Supports filtering by status, date range, read state, and search terms.', 'jetpack-forms' ),
298 'input_schema' => array(
299 'type' => 'object',
300 'default' => array(),
301 'properties' => array(
302 'ids' => array(
303 'type' => 'array',
304 'description' => __( 'Fetch specific responses by their IDs.', 'jetpack-forms' ),
305 'items' => array( 'type' => 'integer' ),
306 ),
307 'page' => array(
308 'type' => 'integer',
309 'description' => __( 'Page number for paginated results.', 'jetpack-forms' ),
310 'default' => 1,
311 'minimum' => 1,
312 ),
313 'per_page' => array(
314 'type' => 'integer',
315 'description' => __( 'Number of responses to return per page.', 'jetpack-forms' ),
316 'default' => 10,
317 'minimum' => 1,
318 'maximum' => 100,
319 ),
320 'parent' => array(
321 'type' => 'array',
322 'description' => __( 'Filter by the page or post ID where the form is embedded.', 'jetpack-forms' ),
323 'items' => array( 'type' => 'integer' ),
324 ),
325 'status' => array(
326 'type' => 'string',
327 'description' => __( 'Filter by response status.', 'jetpack-forms' ),
328 'enum' => self::RESPONSE_STATUSES,
329 ),
330 'is_unread' => array(
331 'type' => 'boolean',
332 'description' => __( 'Set true for unread only, false for read only.', 'jetpack-forms' ),
333 ),
334 'search' => array(
335 'type' => 'string',
336 'description' => __( 'Search within response content and sender info.', 'jetpack-forms' ),
337 ),
338 'before' => array(
339 'type' => 'string',
340 'description' => __( 'Only responses before this date (ISO8601 format).', 'jetpack-forms' ),
341 'format' => 'date-time',
342 ),
343 'after' => array(
344 'type' => 'string',
345 'description' => __( 'Only responses after this date (ISO8601 format).', 'jetpack-forms' ),
346 'format' => 'date-time',
347 ),
348 ),
349 'additionalProperties' => false,
350 ),
351 'execute_callback' => array( __CLASS__, 'get_form_responses' ),
352 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
353 'meta' => array(
354 'annotations' => array(
355 'readonly' => true,
356 'destructive' => false,
357 'idempotent' => true,
358 ),
359 'show_in_rest' => true,
360 'mcp' => array(
361 'public' => true,
362 'type' => 'tool', // default is already "tool", but can be explicit.
363 ),
364 ),
365 );
366 }
367
368 /**
369 * Spec: jetpack-forms/update-response.
370 */
371 private static function spec_update_response(): array {
372 return array(
373 'label' => __( 'Update form response', 'jetpack-forms' ),
374 'description' => __( 'Modify a form response. Use to mark as spam, move to trash, restore from trash, or toggle read/unread state.', 'jetpack-forms' ),
375 'input_schema' => array(
376 'type' => 'object',
377 'required' => array( 'id' ),
378 'properties' => array(
379 'id' => array(
380 'type' => 'integer',
381 'description' => __( 'The response ID to update.', 'jetpack-forms' ),
382 ),
383 'status' => array(
384 'type' => 'string',
385 'description' => __( 'New status: "publish" (restore), "spam" (mark spam), "trash" (soft delete).', 'jetpack-forms' ),
386 'enum' => self::RESPONSE_STATUSES,
387 ),
388 'is_unread' => array(
389 'type' => 'boolean',
390 'description' => __( 'Set false to mark as read, true to mark as unread.', 'jetpack-forms' ),
391 ),
392 ),
393 'additionalProperties' => false,
394 ),
395 'execute_callback' => array( __CLASS__, 'update_form_response' ),
396 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
397 'meta' => array(
398 'annotations' => array(
399 'readonly' => false,
400 'destructive' => false,
401 'idempotent' => true,
402 ),
403 'show_in_rest' => true,
404 'mcp' => array(
405 'public' => true,
406 'type' => 'tool', // default is already "tool", but can be explicit.
407 ),
408 ),
409 );
410 }
411
412 /**
413 * Spec: jetpack-forms/bulk-update-responses.
414 */
415 private static function spec_bulk_update_responses(): array {
416 return array(
417 'label' => __( 'Bulk update form responses', 'jetpack-forms' ),
418 'description' => __( 'Mark multiple responses as spam (or restore from spam) in a single call. Each response is processed individually; the result reports per-id success and any per-id failures so callers can see exactly which responses were updated. Also teaches Akismet from the successful updates.', 'jetpack-forms' ),
419 'input_schema' => array(
420 'type' => 'object',
421 'required' => array( 'action', 'ids' ),
422 'properties' => array(
423 'action' => array(
424 'type' => 'string',
425 'description' => __( 'The bulk action to perform.', 'jetpack-forms' ),
426 'enum' => self::BULK_ACTIONS,
427 ),
428 'ids' => array(
429 'type' => 'array',
430 'description' => __( 'Response IDs to update.', 'jetpack-forms' ),
431 'items' => array( 'type' => 'integer' ),
432 'minItems' => 1,
433 ),
434 ),
435 'additionalProperties' => false,
436 ),
437 'execute_callback' => array( __CLASS__, 'bulk_update_responses' ),
438 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
439 'meta' => array(
440 'annotations' => array(
441 'readonly' => false,
442 'destructive' => false,
443 'idempotent' => true,
444 ),
445 'show_in_rest' => true,
446 'mcp' => array(
447 'public' => true,
448 'type' => 'tool', // default is already "tool", but can be explicit.
449 ),
450 ),
451 );
452 }
453
454 /**
455 * Spec: jetpack-forms/get-status-counts.
456 */
457 private static function spec_get_status_counts(): array {
458 return array(
459 'label' => __( 'Get response status counts', 'jetpack-forms' ),
460 'description' => __( 'Get a summary of form responses grouped by status. Returns counts for inbox (active), spam, and trash. Useful for dashboard stats or checking if there are new responses.', 'jetpack-forms' ),
461 'input_schema' => array(
462 'type' => 'object',
463 'default' => array(),
464 'properties' => array(
465 'search' => array(
466 'type' => 'string',
467 'description' => __( 'Only count responses matching this search term.', 'jetpack-forms' ),
468 ),
469 'parent' => array(
470 'type' => 'integer',
471 'description' => __( 'Only count responses from a specific page or post.', 'jetpack-forms' ),
472 ),
473 'before' => array(
474 'type' => 'string',
475 'description' => __( 'Only count responses before this date (ISO8601 format).', 'jetpack-forms' ),
476 'format' => 'date-time',
477 ),
478 'after' => array(
479 'type' => 'string',
480 'description' => __( 'Only count responses after this date (ISO8601 format).', 'jetpack-forms' ),
481 'format' => 'date-time',
482 ),
483 'is_unread' => array(
484 'type' => 'boolean',
485 'description' => __( 'Set true to count only unread, false for only read.', 'jetpack-forms' ),
486 ),
487 ),
488 'additionalProperties' => false,
489 ),
490 'execute_callback' => array( __CLASS__, 'get_status_counts' ),
491 'permission_callback' => array( __CLASS__, 'can_edit_pages' ),
492 'meta' => array(
493 'annotations' => array(
494 'readonly' => true,
495 'destructive' => false,
496 'idempotent' => true,
497 ),
498 'show_in_rest' => true,
499 'mcp' => array(
500 'public' => true,
501 'type' => 'tool', // default is already "tool", but can be explicit.
502 ),
503 ),
504 );
505 }
506
507 /*
508 ---------------------------------------------------------------------
509 * Permission callbacks
510 * ---------------------------------------------------------------------
511 */
512
513 /**
514 * Permission callback shared by every Forms ability. The delegated REST
515 * controller re-checks the user's edit permission per-route, so this is
516 * a coarse early-rejection gate, not the authoritative authorization.
517 *
518 * @return bool
519 */
520 public static function can_edit_pages() {
521 return current_user_can( 'edit_pages' );
522 }
523
524 /*
525 ---------------------------------------------------------------------
526 * Execute callbacks
527 * ---------------------------------------------------------------------
528 */
529
530 /**
531 * Execute: list-forms.
532 *
533 * Delegates to GET /wp/v2/jetpack-forms with dashboard context, then
534 * reshapes to a compact format for AI consumption.
535 *
536 * @param array $args Arguments from the ability input.
537 * @return array|\WP_Error
538 */
539 public static function list_forms( $args = array() ) {
540 $args = is_array( $args ) ? $args : array();
541 $request = new \WP_REST_Request( 'GET', '/wp/v2/jetpack-forms' );
542 $request->set_param( 'jetpack_forms_context', 'dashboard' );
543 self::set_params_from_args( $request, $args, array( 'page', 'per_page', 'search', 'status' ) );
544
545 $data = self::dispatch( $request );
546 if ( is_wp_error( $data ) ) {
547 return $data;
548 }
549
550 if ( ! is_array( $data ) ) {
551 return array();
552 }
553
554 $result = array();
555 foreach ( $data as $form ) {
556 $result[] = array(
557 'id' => $form['id'],
558 'title' => $form['title']['rendered'] ?? '',
559 'status' => $form['status'],
560 'entries_count' => $form['entries_count'] ?? 0,
561 'edit_url' => $form['edit_url'] ?? '',
562 'date' => $form['date'],
563 'modified' => $form['modified'],
564 );
565 }
566
567 return $result;
568 }
569
570 /**
571 * Execute: get-form.
572 *
573 * Delegates to GET /wp/v2/jetpack-forms/{id} with `context=edit` so the
574 * raw block content comes back in the response — no second `get_post()`
575 * fetch needed.
576 *
577 * @param array $args Arguments from the ability input.
578 * @return array|\WP_Error
579 */
580 public static function get_form( $args ) {
581 if ( ! isset( $args['id'] ) ) {
582 return new \WP_Error( 'missing_id', __( 'Form ID is required.', 'jetpack-forms' ) );
583 }
584
585 $request = new \WP_REST_Request( 'GET', '/wp/v2/jetpack-forms/' . absint( $args['id'] ) );
586 $request->set_param( 'context', 'edit' );
587
588 $data = self::dispatch( $request );
589 if ( is_wp_error( $data ) ) {
590 return $data;
591 }
592
593 $raw_content = $data['content']['raw'] ?? '';
594
595 return array(
596 'id' => $data['id'],
597 'title' => $data['title']['raw'] ?? $data['title']['rendered'] ?? '',
598 'status' => $data['status'],
599 'fields' => self::extract_fields_from_content( $raw_content ),
600 'date' => $data['date'],
601 'modified' => $data['modified'],
602 'edit_url' => $data['link'] ?? get_edit_post_link( $data['id'], 'raw' ),
603 );
604 }
605
606 /**
607 * Execute: create-form.
608 *
609 * @param array $args Arguments from the ability input.
610 * @return array|\WP_Error
611 */
612 public static function create_form( $args ) {
613 if ( empty( $args['title'] ) ) {
614 return new \WP_Error( 'missing_title', __( 'Form title is required.', 'jetpack-forms' ) );
615 }
616
617 $content = $args['content'] ?? '';
618 if ( '' === $content ) {
619 $content = self::DEFAULT_FORM_CONTENT;
620 }
621
622 $request = new \WP_REST_Request( 'POST', '/wp/v2/jetpack-forms' );
623 $request->set_body_params(
624 array(
625 'title' => $args['title'],
626 'content' => $content,
627 'status' => $args['status'] ?? 'publish',
628 )
629 );
630
631 $data = self::dispatch( $request );
632 if ( is_wp_error( $data ) ) {
633 return $data;
634 }
635
636 return array(
637 'id' => $data['id'],
638 'title' => $data['title']['raw'] ?? $data['title']['rendered'] ?? '',
639 'status' => $data['status'],
640 'edit_url' => get_edit_post_link( $data['id'], 'raw' ),
641 );
642 }
643
644 /**
645 * Execute: delete-form.
646 *
647 * @param array $args Arguments from the ability input.
648 * @return array|\WP_Error
649 */
650 public static function delete_form( $args ) {
651 if ( ! isset( $args['id'] ) ) {
652 return new \WP_Error( 'missing_id', __( 'Form ID is required.', 'jetpack-forms' ) );
653 }
654
655 $request = new \WP_REST_Request( 'DELETE', '/wp/v2/jetpack-forms/' . absint( $args['id'] ) );
656
657 $data = self::dispatch( $request );
658 if ( is_wp_error( $data ) ) {
659 return $data;
660 }
661
662 return array(
663 'id' => $data['id'] ?? absint( $args['id'] ),
664 'deleted' => true,
665 'status' => $data['status'] ?? 'trash',
666 );
667 }
668
669 /**
670 * Execute: get-responses.
671 *
672 * @param array $args Arguments from the ability input.
673 * @return array|\WP_Error
674 */
675 public static function get_form_responses( $args = array() ) {
676 $args = is_array( $args ) ? $args : array();
677 $request = new \WP_REST_Request( 'GET', '/wp/v2/feedback' );
678 self::set_params_from_args( $request, $args, array( 'page', 'per_page', 'parent', 'status', 'is_unread', 'search', 'before', 'after' ) );
679
680 if ( isset( $args['ids'] ) && is_array( $args['ids'] ) ) {
681 $request->set_param( 'include', $args['ids'] );
682 }
683
684 return self::dispatch( $request );
685 }
686
687 /**
688 * Execute: update-response.
689 *
690 * @param array $args Arguments from the ability input.
691 * @return array|\WP_Error
692 */
693 public static function update_form_response( $args ) {
694 if ( ! isset( $args['id'] ) ) {
695 return new \WP_Error( 'missing_id', __( 'Response ID is required.', 'jetpack-forms' ) );
696 }
697
698 $id = absint( $args['id'] );
699 $result = array();
700
701 if ( isset( $args['status'] ) ) {
702 $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id );
703 $request->set_body_params( array( 'status' => $args['status'] ) );
704 $data = self::dispatch( $request );
705 if ( is_wp_error( $data ) ) {
706 return $data;
707 }
708 $result = $data;
709 }
710
711 if ( isset( $args['is_unread'] ) ) {
712 $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id . '/read' );
713 $request->set_body_params( array( 'is_unread' => $args['is_unread'] ) );
714 $data = self::dispatch( $request );
715 if ( is_wp_error( $data ) ) {
716 return $data;
717 }
718 $result = array_merge( $result, $data );
719 }
720
721 return $result;
722 }
723
724 /**
725 * Execute: bulk-update-responses.
726 *
727 * The `/wp/v2/feedback/bulk_actions` REST endpoint only teaches Akismet —
728 * it does not change post status. The dashboard handles bulk spam/not-spam
729 * by issuing per-id status updates first, then calling bulk_actions to
730 * teach Akismet from the successful flips. We mirror that here so callers
731 * get a faithful per-id confirmation: each id either lands in `succeeded`
732 * or in `failed` with the underlying error code/message.
733 *
734 * @param array $args Arguments from the ability input.
735 * @return array|\WP_Error
736 */
737 public static function bulk_update_responses( $args ) {
738 if ( empty( $args['action'] ) || empty( $args['ids'] ) || ! is_array( $args['ids'] ) ) {
739 return new \WP_Error( 'missing_params', __( 'Action and IDs are required.', 'jetpack-forms' ) );
740 }
741
742 $action = $args['action'];
743 $target_status = 'mark_as_spam' === $action ? 'spam' : 'publish';
744 $ids = array_values( array_unique( array_map( 'absint', $args['ids'] ) ) );
745
746 $succeeded = array();
747 $failed = array();
748 foreach ( $ids as $id ) {
749 if ( $id <= 0 ) {
750 $failed[] = array(
751 'id' => $id,
752 'code' => 'invalid_id',
753 'message' => __( 'Response IDs must be positive integers.', 'jetpack-forms' ),
754 );
755 continue;
756 }
757 $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id );
758 $request->set_body_params( array( 'status' => $target_status ) );
759 $data = self::dispatch( $request );
760 if ( is_wp_error( $data ) ) {
761 $failed[] = array(
762 'id' => $id,
763 'code' => $data->get_error_code(),
764 'message' => $data->get_error_message(),
765 );
766 continue;
767 }
768 $succeeded[] = $id;
769 }
770
771 // Teach Akismet from the responses whose status actually flipped.
772 // Akismet learning is best-effort and independent of the status update,
773 // so a failure here doesn't roll back the per-id changes above.
774 if ( ! empty( $succeeded ) ) {
775 $teach = new \WP_REST_Request( 'POST', '/wp/v2/feedback/bulk_actions' );
776 $teach->set_body_params(
777 array(
778 'action' => $action,
779 'post_ids' => $succeeded,
780 )
781 );
782 self::dispatch( $teach );
783 }
784
785 return array(
786 'action' => $action,
787 'succeeded' => $succeeded,
788 'failed' => $failed,
789 );
790 }
791
792 /**
793 * Execute: get-status-counts.
794 *
795 * @param array $args Arguments from the ability input.
796 * @return array|\WP_Error
797 */
798 public static function get_status_counts( $args = array() ) {
799 $args = is_array( $args ) ? $args : array();
800 $request = new \WP_REST_Request( 'GET', '/wp/v2/feedback/counts' );
801 self::set_params_from_args( $request, $args, array( 'search', 'parent', 'before', 'after', 'is_unread' ) );
802
803 return self::dispatch( $request );
804 }
805
806 /*
807 ---------------------------------------------------------------------
808 * Helpers
809 * ---------------------------------------------------------------------
810 */
811
812 /**
813 * Dispatch an internal REST request and unwrap the response.
814 *
815 * @param \WP_REST_Request $request The REST request to dispatch.
816 * @return array|\WP_Error Response data array, or WP_Error on failure.
817 */
818 private static function dispatch( $request ) {
819 $response = rest_do_request( $request );
820 if ( $response->is_error() ) {
821 return $response->as_error();
822 }
823 return $response->get_data();
824 }
825
826 /**
827 * Copy a whitelisted subset of `$args` onto a REST request as parameters.
828 *
829 * @param \WP_REST_Request $request Target request.
830 * @param array $args Caller-supplied arguments.
831 * @param array $keys Allowed keys to forward.
832 */
833 private static function set_params_from_args( \WP_REST_Request $request, array $args, array $keys ): void {
834 foreach ( $keys as $key ) {
835 if ( isset( $args[ $key ] ) ) {
836 $request->set_param( $key, $args[ $key ] );
837 }
838 }
839 }
840
841 /**
842 * Extract field definitions from raw block content.
843 *
844 * Walks `jetpack/field-*` blocks and projects each into a compact
845 * `{ label, type, required, options?, placeholder?, help_text? }` shape. Modern
846 * field blocks store the label/placeholder in `jetpack/label` and
847 * `jetpack/input` sub-blocks; legacy fixtures keep them inline as
848 * top-level attrs. Both layouts are supported.
849 *
850 * @param string $raw_content Raw block content (typically `content.raw` from REST).
851 * @return array
852 */
853 private static function extract_fields_from_content( string $raw_content ): array {
854 if ( '' === $raw_content ) {
855 return array();
856 }
857 $fields = array();
858 self::collect_field_blocks( parse_blocks( $raw_content ), $fields );
859 return $fields;
860 }
861
862 /**
863 * Recursively walk parsed blocks and append field definitions to `$fields`.
864 *
865 * Field blocks are not recursed into — their inner blocks are layout
866 * sub-blocks (`jetpack/label`, `jetpack/input`), not nested fields.
867 * Non-field containers (columns, groups, the contact-form block itself)
868 * are recursed so fields nested inside them still get picked up.
869 *
870 * @param array $blocks Parsed blocks.
871 * @param array $fields Reference to the fields array being built.
872 */
873 private static function collect_field_blocks( array $blocks, array &$fields ): void {
874 foreach ( $blocks as $block ) {
875 $name = $block['blockName'] ?? '';
876
877 if ( strpos( $name, 'jetpack/field-' ) === 0 ) {
878 $summary = self::summarize_field_block( $block );
879 if ( null !== $summary ) {
880 $fields[] = $summary;
881 }
882 continue;
883 }
884
885 if ( ! empty( $block['innerBlocks'] ) ) {
886 self::collect_field_blocks( $block['innerBlocks'], $fields );
887 }
888 }
889 }
890
891 /**
892 * Project a single `jetpack/field-*` block into the compact field shape.
893 *
894 * @param array $block Parsed block array.
895 * @return array|null Field summary, or null if the block has no usable label.
896 */
897 private static function summarize_field_block( array $block ): ?array {
898 $attrs = $block['attrs'] ?? array();
899 $inner_attrs = self::collect_inner_attrs( $block['innerBlocks'] ?? array() );
900 $label_attrs = $inner_attrs['jetpack/label'] ?? array();
901 $input_attrs = $inner_attrs['jetpack/input'] ?? array();
902
903 $label = (string) ( $label_attrs['label'] ?? $attrs['label'] ?? '' );
904 if ( '' === $label ) {
905 return null;
906 }
907
908 $field = array(
909 'label' => $label,
910 'type' => str_replace( 'jetpack/field-', '', (string) ( $block['blockName'] ?? '' ) ),
911 'required' => ! empty( $attrs['required'] ),
912 );
913
914 if ( ! empty( $attrs['options'] ) ) {
915 $field['options'] = $attrs['options'];
916 }
917
918 $placeholder = $input_attrs['placeholder'] ?? $attrs['placeholder'] ?? '';
919 if ( '' !== $placeholder ) {
920 $field['placeholder'] = $placeholder;
921 }
922
923 $help_text = trim( (string) ( $attrs['helpText'] ?? '' ) );
924 if ( '' !== $help_text ) {
925 $field['help_text'] = $help_text;
926 }
927
928 return $field;
929 }
930
931 /**
932 * Build a `block_name => attrs` lookup from the field's direct children.
933 *
934 * @param array $inner_blocks Direct children of a field block.
935 * @return array
936 */
937 private static function collect_inner_attrs( array $inner_blocks ): array {
938 $out = array();
939 foreach ( $inner_blocks as $child ) {
940 $name = $child['blockName'] ?? '';
941 if ( '' !== $name && ! isset( $out[ $name ] ) ) {
942 $out[ $name ] = $child['attrs'] ?? array();
943 }
944 }
945 return $out;
946 }
947 }
948