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