|
12 | 12 |
|
13 | 13 | namespace GratisAiAgent\Abilities; |
14 | 14 |
|
| 15 | +use GratisAiAgent\Models\MarkdownToBlocks; |
15 | 16 | use WP_Error; |
16 | 17 | use WP_Post; |
17 | 18 |
|
@@ -93,47 +94,51 @@ public static function register_abilities(): void { |
93 | 94 | 'ai-agent/create-post', |
94 | 95 | [ |
95 | 96 | 'label' => __( 'Create Post', 'gratis-ai-agent' ), |
96 | | - 'description' => __( 'Create a new WordPress post or custom post type entry. Supports title, content, excerpt, status, categories, tags, and meta fields.', 'gratis-ai-agent' ), |
| 97 | + 'description' => __( 'Create a new WordPress post or page. This is the PRIMARY tool for creating any content — blog posts, landing pages, about pages, etc. Write content directly as HTML or markdown (auto-converted to Gutenberg blocks). Set post_type to "page" for pages or "post" for blog posts. Set status to "publish" to make it live immediately.', 'gratis-ai-agent' ), |
97 | 98 | 'category' => 'gratis-ai-agent', |
98 | 99 | 'input_schema' => [ |
99 | 100 | 'type' => 'object', |
100 | 101 | 'properties' => [ |
101 | | - 'title' => [ |
| 102 | + 'title' => [ |
102 | 103 | 'type' => 'string', |
103 | 104 | 'description' => 'The post title.', |
104 | 105 | ], |
105 | | - 'content' => [ |
| 106 | + 'content' => [ |
106 | 107 | 'type' => 'string', |
107 | | - 'description' => 'The post content (HTML or plain text).', |
| 108 | + 'description' => 'The post content. Write in markdown (headings with ##, lists with -, bold with **) or HTML — markdown is automatically converted to Gutenberg blocks.', |
108 | 109 | ], |
109 | | - 'excerpt' => [ |
| 110 | + 'excerpt' => [ |
110 | 111 | 'type' => 'string', |
111 | 112 | 'description' => 'Optional post excerpt.', |
112 | 113 | ], |
113 | | - 'status' => [ |
| 114 | + 'status' => [ |
114 | 115 | 'type' => 'string', |
115 | 116 | 'description' => 'Post status: "draft" (default), "publish", "pending", "private", or "future".', |
116 | 117 | 'enum' => [ 'draft', 'publish', 'pending', 'private', 'future' ], |
117 | 118 | ], |
118 | | - 'post_type' => [ |
| 119 | + 'post_type' => [ |
119 | 120 | 'type' => 'string', |
120 | 121 | 'description' => 'Post type (default: "post"). Use "page" for pages.', |
121 | 122 | ], |
122 | | - 'categories' => [ |
| 123 | + 'categories' => [ |
123 | 124 | 'type' => 'array', |
124 | 125 | 'description' => 'Array of category IDs or names to assign.', |
125 | 126 | 'items' => [ 'type' => 'string' ], |
126 | 127 | ], |
127 | | - 'tags' => [ |
| 128 | + 'tags' => [ |
128 | 129 | 'type' => 'array', |
129 | 130 | 'description' => 'Array of tag names to assign.', |
130 | 131 | 'items' => [ 'type' => 'string' ], |
131 | 132 | ], |
132 | | - 'meta' => [ |
| 133 | + 'featured_image_id' => [ |
| 134 | + 'type' => 'integer', |
| 135 | + 'description' => 'Attachment ID to set as the featured image (e.g. from import-stock-image result).', |
| 136 | + ], |
| 137 | + 'meta' => [ |
133 | 138 | 'type' => 'object', |
134 | 139 | 'description' => 'Key-value pairs of post meta to set.', |
135 | 140 | ], |
136 | | - 'site_url' => [ |
| 141 | + 'site_url' => [ |
137 | 142 | 'type' => 'string', |
138 | 143 | 'description' => 'Subsite URL for multisite. Omit for the main site.', |
139 | 144 | ], |
@@ -171,42 +176,46 @@ public static function register_abilities(): void { |
171 | 176 | 'input_schema' => [ |
172 | 177 | 'type' => 'object', |
173 | 178 | 'properties' => [ |
174 | | - 'post_id' => [ |
| 179 | + 'post_id' => [ |
175 | 180 | 'type' => 'integer', |
176 | 181 | 'description' => 'The ID of the post to update.', |
177 | 182 | ], |
178 | | - 'title' => [ |
| 183 | + 'title' => [ |
179 | 184 | 'type' => 'string', |
180 | 185 | 'description' => 'New post title.', |
181 | 186 | ], |
182 | | - 'content' => [ |
| 187 | + 'content' => [ |
183 | 188 | 'type' => 'string', |
184 | 189 | 'description' => 'New post content.', |
185 | 190 | ], |
186 | | - 'excerpt' => [ |
| 191 | + 'excerpt' => [ |
187 | 192 | 'type' => 'string', |
188 | 193 | 'description' => 'New post excerpt.', |
189 | 194 | ], |
190 | | - 'status' => [ |
| 195 | + 'status' => [ |
191 | 196 | 'type' => 'string', |
192 | 197 | 'description' => 'New post status.', |
193 | 198 | 'enum' => [ 'draft', 'publish', 'pending', 'private', 'future', 'trash' ], |
194 | 199 | ], |
195 | | - 'categories' => [ |
| 200 | + 'categories' => [ |
196 | 201 | 'type' => 'array', |
197 | 202 | 'description' => 'Replace categories with this array of IDs or names.', |
198 | 203 | 'items' => [ 'type' => 'string' ], |
199 | 204 | ], |
200 | | - 'tags' => [ |
| 205 | + 'tags' => [ |
201 | 206 | 'type' => 'array', |
202 | 207 | 'description' => 'Replace tags with this array of names.', |
203 | 208 | 'items' => [ 'type' => 'string' ], |
204 | 209 | ], |
205 | | - 'meta' => [ |
| 210 | + 'featured_image_id' => [ |
| 211 | + 'type' => 'integer', |
| 212 | + 'description' => 'Attachment ID to set as the featured image.', |
| 213 | + ], |
| 214 | + 'meta' => [ |
206 | 215 | 'type' => 'object', |
207 | 216 | 'description' => 'Key-value pairs of post meta to update.', |
208 | 217 | ], |
209 | | - 'site_url' => [ |
| 218 | + 'site_url' => [ |
210 | 219 | 'type' => 'string', |
211 | 220 | 'description' => 'Subsite URL for multisite. Omit for the main site.', |
212 | 221 | ], |
@@ -279,6 +288,82 @@ public static function register_abilities(): void { |
279 | 288 | }, |
280 | 289 | ] |
281 | 290 | ); |
| 291 | + |
| 292 | + wp_register_ability( |
| 293 | + 'ai-agent/create-post-with-image', |
| 294 | + [ |
| 295 | + 'label' => __( 'Create Post with Stock Image', 'gratis-ai-agent' ), |
| 296 | + 'description' => __( 'Create a WordPress post or page AND automatically import a stock image as the featured image — all in one step. Use this when you need to create content with an image.', 'gratis-ai-agent' ), |
| 297 | + 'category' => 'gratis-ai-agent', |
| 298 | + 'input_schema' => [ |
| 299 | + 'type' => 'object', |
| 300 | + 'properties' => [ |
| 301 | + 'title' => [ |
| 302 | + 'type' => 'string', |
| 303 | + 'description' => 'The post title.', |
| 304 | + ], |
| 305 | + 'content' => [ |
| 306 | + 'type' => 'string', |
| 307 | + 'description' => 'The post content in markdown or HTML.', |
| 308 | + ], |
| 309 | + 'excerpt' => [ |
| 310 | + 'type' => 'string', |
| 311 | + 'description' => 'Optional post excerpt / meta description.', |
| 312 | + ], |
| 313 | + 'status' => [ |
| 314 | + 'type' => 'string', |
| 315 | + 'enum' => [ 'draft', 'publish', 'pending', 'private', 'future' ], |
| 316 | + 'description' => 'Post status (default: draft).', |
| 317 | + ], |
| 318 | + 'post_type' => [ |
| 319 | + 'type' => 'string', |
| 320 | + 'description' => 'Post type (default: post). Use page for pages.', |
| 321 | + ], |
| 322 | + 'categories' => [ |
| 323 | + 'type' => 'array', |
| 324 | + 'items' => [ 'type' => 'string' ], |
| 325 | + 'description' => 'Category names.', |
| 326 | + ], |
| 327 | + 'tags' => [ |
| 328 | + 'type' => 'array', |
| 329 | + 'items' => [ 'type' => 'string' ], |
| 330 | + 'description' => 'Tag names.', |
| 331 | + ], |
| 332 | + 'image_keyword' => [ |
| 333 | + 'type' => 'string', |
| 334 | + 'description' => 'Search keyword for the stock image.', |
| 335 | + ], |
| 336 | + 'meta' => [ |
| 337 | + 'type' => 'object', |
| 338 | + 'description' => 'Key-value pairs of post meta.', |
| 339 | + ], |
| 340 | + 'site_url' => [ |
| 341 | + 'type' => 'string', |
| 342 | + 'description' => 'Subsite URL for multisite.', |
| 343 | + ], |
| 344 | + ], |
| 345 | + 'required' => [ 'title', 'content', 'image_keyword' ], |
| 346 | + ], |
| 347 | + 'output_schema' => [ |
| 348 | + 'type' => 'object', |
| 349 | + 'properties' => [ |
| 350 | + 'post_id' => [ 'type' => 'integer' ], |
| 351 | + 'permalink' => [ 'type' => 'string' ], |
| 352 | + 'status' => [ 'type' => 'string' ], |
| 353 | + 'post_type' => [ 'type' => 'string' ], |
| 354 | + 'featured_image' => [ 'type' => 'object' ], |
| 355 | + ], |
| 356 | + ], |
| 357 | + 'meta' => [ |
| 358 | + 'annotations' => [ 'destructive' => false ], |
| 359 | + 'show_in_rest' => true, |
| 360 | + ], |
| 361 | + 'execute_callback' => [ __CLASS__, 'handle_create_post_with_image' ], |
| 362 | + 'permission_callback' => function (): bool { |
| 363 | + return current_user_can( 'edit_posts' ) && current_user_can( 'upload_files' ); |
| 364 | + }, |
| 365 | + ] |
| 366 | + ); |
282 | 367 | } |
283 | 368 |
|
284 | 369 | /** |
@@ -348,12 +433,13 @@ public static function handle_get_post( array $input ) { |
348 | 433 | * @return array<string, mixed>|WP_Error |
349 | 434 | */ |
350 | 435 | public static function handle_create_post( array $input ) { |
351 | | - $title = sanitize_text_field( $input['title'] ?? '' ); |
352 | | - $content = wp_kses_post( $input['content'] ?? '' ); |
353 | | - $excerpt = sanitize_textarea_field( $input['excerpt'] ?? '' ); |
354 | | - $status = sanitize_text_field( $input['status'] ?? 'draft' ); |
355 | | - $post_type = sanitize_text_field( $input['post_type'] ?? 'post' ); |
356 | | - $site_url = $input['site_url'] ?? ''; |
| 436 | + $title = sanitize_text_field( $input['title'] ?? '' ); |
| 437 | + $raw_content = $input['content'] ?? ''; |
| 438 | + $content = wp_kses_post( self::maybe_convert_markdown( $raw_content ) ); |
| 439 | + $excerpt = sanitize_textarea_field( $input['excerpt'] ?? '' ); |
| 440 | + $status = sanitize_text_field( $input['status'] ?? 'draft' ); |
| 441 | + $post_type = sanitize_text_field( $input['post_type'] ?? 'post' ); |
| 442 | + $site_url = $input['site_url'] ?? ''; |
357 | 443 |
|
358 | 444 | if ( empty( $title ) ) { |
359 | 445 | return new WP_Error( 'ai_agent_empty_title', __( 'Post title is required.', 'gratis-ai-agent' ) ); |
@@ -409,6 +495,12 @@ public static function handle_create_post( array $input ) { |
409 | 495 | wp_set_post_tags( $post_id, $tag_names ); |
410 | 496 | } |
411 | 497 |
|
| 498 | + // Set featured image if provided. |
| 499 | + $featured_image_id = (int) ( $input['featured_image_id'] ?? 0 ); |
| 500 | + if ( $featured_image_id > 0 ) { |
| 501 | + set_post_thumbnail( $post_id, $featured_image_id ); |
| 502 | + } |
| 503 | + |
412 | 504 | // Set post meta. |
413 | 505 | $meta = $input['meta'] ?? []; |
414 | 506 | if ( ! empty( $meta ) && is_array( $meta ) ) { |
@@ -478,7 +570,7 @@ public static function handle_update_post( array $input ) { |
478 | 570 | $post_data['post_title'] = sanitize_text_field( $input['title'] ); |
479 | 571 | } |
480 | 572 | if ( isset( $input['content'] ) ) { |
481 | | - $post_data['post_content'] = wp_kses_post( $input['content'] ); |
| 573 | + $post_data['post_content'] = wp_kses_post( self::maybe_convert_markdown( $input['content'] ) ); |
482 | 574 | } |
483 | 575 | if ( isset( $input['excerpt'] ) ) { |
484 | 576 | $post_data['post_excerpt'] = sanitize_textarea_field( $input['excerpt'] ); |
@@ -512,6 +604,12 @@ public static function handle_update_post( array $input ) { |
512 | 604 | wp_set_post_tags( $post_id, $tag_names ); |
513 | 605 | } |
514 | 606 |
|
| 607 | + // Update featured image if provided. |
| 608 | + $featured_image_id = isset( $input['featured_image_id'] ) ? (int) $input['featured_image_id'] : 0; |
| 609 | + if ( $featured_image_id > 0 ) { |
| 610 | + set_post_thumbnail( $post_id, $featured_image_id ); |
| 611 | + } |
| 612 | + |
515 | 613 | // Update meta if provided. |
516 | 614 | if ( isset( $input['meta'] ) && is_array( $input['meta'] ) ) { |
517 | 615 | foreach ( $input['meta'] as $key => $value ) { |
@@ -598,6 +696,96 @@ public static function handle_delete_post( array $input ) { |
598 | 696 | ]; |
599 | 697 | } |
600 | 698 |
|
| 699 | + |
| 700 | + /** |
| 701 | + * Handle the create-post-with-image composite ability. |
| 702 | + * |
| 703 | + * Creates a post AND imports a stock image as the featured image in one |
| 704 | + * atomic operation. |
| 705 | + * |
| 706 | + * @param array<string, mixed> $input Input with title, content, image_keyword, etc. |
| 707 | + * @return array<string, mixed>|WP_Error |
| 708 | + */ |
| 709 | + public static function handle_create_post_with_image( array $input ) { |
| 710 | + $image_keyword = sanitize_text_field( $input['image_keyword'] ?? '' ); |
| 711 | + $image_result = null; |
| 712 | + |
| 713 | + if ( ! empty( $image_keyword ) ) { |
| 714 | + $image_input = [ |
| 715 | + 'keyword' => $image_keyword, |
| 716 | + 'site_url' => $input['site_url'] ?? '', |
| 717 | + ]; |
| 718 | + $image_result = StockImageAbilities::handle_import( $image_input ); |
| 719 | + if ( is_array( $image_result ) && ! empty( $image_result['attachment_id'] ) ) { |
| 720 | + $input['featured_image_id'] = (int) $image_result['attachment_id']; |
| 721 | + } |
| 722 | + } |
| 723 | + |
| 724 | + $post_result = self::handle_create_post( $input ); |
| 725 | + |
| 726 | + if ( is_wp_error( $post_result ) ) { |
| 727 | + return $post_result; |
| 728 | + } |
| 729 | + |
| 730 | + if ( is_array( $image_result ) && ! empty( $image_result['attachment_id'] ) ) { |
| 731 | + $post_result['featured_image'] = [ |
| 732 | + 'attachment_id' => $image_result['attachment_id'], |
| 733 | + 'url' => $image_result['url'] ?? '', |
| 734 | + ]; |
| 735 | + } elseif ( is_wp_error( $image_result ) ) { |
| 736 | + $post_result['featured_image_error'] = $image_result->get_error_message(); |
| 737 | + } |
| 738 | + |
| 739 | + return $post_result; |
| 740 | + } |
| 741 | + |
| 742 | + /** |
| 743 | + * Detect whether content looks like markdown and convert to Gutenberg blocks. |
| 744 | + * |
| 745 | + * @param string $content Raw content from the model. |
| 746 | + * @return string Content ready for post_content (blocks HTML or original). |
| 747 | + */ |
| 748 | + private static function maybe_convert_markdown( string $content ): string { |
| 749 | + if ( '' === $content ) { |
| 750 | + return $content; |
| 751 | + } |
| 752 | + |
| 753 | + if ( str_contains( $content, '<!-- wp:' ) ) { |
| 754 | + return $content; |
| 755 | + } |
| 756 | + |
| 757 | + $html_block_tags = preg_match_all( '/<(?:p|h[1-6]|div|section|ul|ol|table|blockquote|figure|header|footer|article|nav)\b/i', $content ); |
| 758 | + if ( $html_block_tags >= 3 ) { |
| 759 | + return $content; |
| 760 | + } |
| 761 | + |
| 762 | + $markdown_signals = 0; |
| 763 | + if ( preg_match( '/^#{1,6}\s+\S/m', $content ) ) { |
| 764 | + ++$markdown_signals; |
| 765 | + } |
| 766 | + if ( preg_match( '/\*{1,2}[^*\n]+\*{1,2}/', $content ) || preg_match( '/_{1,2}[^_\n]+_{1,2}/', $content ) ) { |
| 767 | + ++$markdown_signals; |
| 768 | + } |
| 769 | + if ( preg_match( '/^[\-\*]\s+\S/m', $content ) ) { |
| 770 | + ++$markdown_signals; |
| 771 | + } |
| 772 | + if ( preg_match( '/^\d+\.\s+\S/m', $content ) ) { |
| 773 | + ++$markdown_signals; |
| 774 | + } |
| 775 | + if ( preg_match( '/\[[^\]]+\]\([^)]+\)/', $content ) ) { |
| 776 | + ++$markdown_signals; |
| 777 | + } |
| 778 | + if ( str_contains( $content, '```' ) ) { |
| 779 | + ++$markdown_signals; |
| 780 | + } |
| 781 | + |
| 782 | + if ( $markdown_signals < 2 ) { |
| 783 | + return $content; |
| 784 | + } |
| 785 | + |
| 786 | + return MarkdownToBlocks::convert( $content ); |
| 787 | + } |
| 788 | + |
601 | 789 | /** |
602 | 790 | * Resolve an array of category IDs or names to an array of IDs. |
603 | 791 | * |
|
0 commit comments