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