PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 13.8.3 All 506 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-a.5, at jetpack_vendor/automattic/jetpack-forms/src/abilities/class-forms-abilities.php

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