Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.51% covered (success)
90.51%
496 / 548
70.37% covered (warning)
70.37%
19 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Forms_Abilities
90.66% covered (success)
90.66%
495 / 546
70.37% covered (warning)
70.37%
19 / 27
76.22
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%
47 / 47
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_form
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 spec_create_form
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
1
 spec_delete_form
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_responses
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
1 / 1
1
 spec_update_response
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 spec_bulk_update_responses
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_status_counts
100.00% covered (success)
100.00%
47 / 47
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
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.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
11namespace Automattic\Jetpack\Forms\Abilities;
12
13use Automattic\Jetpack\WP_Abilities\Registrar;
14
15if ( ! 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 */
26class 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}