Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.61% covered (success)
90.61%
492 / 543
70.37% covered (warning)
70.37%
19 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Forms_Abilities
90.61% covered (success)
90.61%
492 / 543
70.37% covered (warning)
70.37%
19 / 27
75.18
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
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
11namespace Automattic\Jetpack\Forms\Abilities;
12
13use 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 */
22class 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? }` 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        return $field;
920    }
921
922    /**
923     * Build a `block_name => attrs` lookup from the field's direct children.
924     *
925     * @param array $inner_blocks Direct children of a field block.
926     * @return array
927     */
928    private static function collect_inner_attrs( array $inner_blocks ): array {
929        $out = array();
930        foreach ( $inner_blocks as $child ) {
931            $name = $child['blockName'] ?? '';
932            if ( '' !== $name && ! isset( $out[ $name ] ) ) {
933                $out[ $name ] = $child['attrs'] ?? array();
934            }
935        }
936        return $out;
937    }
938}