Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 330
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CustomPostTypes
0.00% covered (danger)
0.00%
0 / 330
0.00% covered (danger)
0.00%
0 / 2
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_abilities
0.00% covered (danger)
0.00%
0 / 329
0.00% covered (danger)
0.00%
0 / 1
600
1<?php
2declare( strict_types=1 );
3
4namespace BLU\Abilities;
5
6/**
7 * CustomPostTypes abilities for WordPress custom post types.
8 *
9 * Every ability that takes a `post_type` argument routes through
10 * blu_resolve_post_type() so callers can pass the slug, REST base,
11 * or any label form. Single-item and list responses use the
12 * blu_project_post_* helpers so the returned shape always leads
13 * with the numeric `id` (which LLMs would otherwise routinely drop).
14 */
15class CustomPostTypes {
16
17    /**
18     * Constructor - registers custom post type abilities.
19     */
20    public function __construct() {
21        $this->register_abilities();
22    }
23
24    /**
25     * Register custom post type abilities.
26     */
27    private function register_abilities(): void {
28        // List post types
29        blu_register_ability(
30            'blu/list-post-types',
31            array(
32                'label'               => 'List Post Types',
33                'description'         => 'List all registered WordPress post types (built-in and custom). Use this to discover which post type slugs exist before creating or searching items.',
34                'category'            => 'blu-mcp',
35                'input_schema'        => array(
36                    'type' => 'object',
37                ),
38                'execute_callback'    => function () {
39                    $request  = new \WP_REST_Request( 'GET', '/wp/v2/types' );
40                    $response = rest_do_request( $request );
41                    return blu_standardize_rest_response( $response );
42                },
43                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
44                'meta'                => array(
45                    'annotations' => array(
46                        'readonly'    => true,
47                        'destructive' => false,
48                        'idempotent'  => true,
49                    ),
50                ),
51            )
52        );
53
54        // Search custom post types
55        blu_register_ability(
56            'blu/cpt-search',
57            array(
58                'label'               => 'Search Custom Post Type Items',
59                'description'         => 'Search and filter content items within a custom post type with pagination. Accepts friendly post_type identifiers (slug, REST base, or label). Results are slim projections — note the `id` of each result to use with blu/get-cpt, blu/update-cpt, or blu/delete-cpt.',
60                'category'            => 'blu-mcp',
61                'input_schema'        => array(
62                    'type'       => 'object',
63                    'properties' => array(
64                        'post_type' => array(
65                            'type'        => 'string',
66                            'description' => 'Post type identifier — slug ("bmcp_book"), REST base ("books"), or label ("Books"/"Book"). Case-insensitive.',
67                        ),
68                        'search'    => array(
69                            'type'        => 'string',
70                            'description' => 'Search term',
71                        ),
72                        'author'    => array(
73                            'type'        => 'integer',
74                            'description' => 'Filter by author ID',
75                        ),
76                        'status'    => array(
77                            'type'        => 'string',
78                            'description' => 'Filter by post status. Defaults to "publish". Pass "any" to include drafts and other statuses.',
79                        ),
80                        'page'      => array(
81                            'type'        => 'integer',
82                            'description' => 'Page number',
83                            'default'     => 1,
84                        ),
85                        'per_page'  => array(
86                            'type'        => 'integer',
87                            'description' => 'Items per page',
88                            'default'     => 10,
89                        ),
90                    ),
91                    'required'   => array( 'post_type' ),
92                ),
93                'execute_callback'    => function ( $input ) {
94                    $resolved = blu_resolve_post_type( (string) $input['post_type'] );
95                    if ( null === $resolved ) {
96                        return blu_post_type_not_found_response( (string) $input['post_type'] );
97                    }
98
99                    $page     = $input['page'] ?? 1;
100                    $per_page = $input['per_page'] ?? 10;
101
102                    $args = array(
103                        'post_type'      => $resolved,
104                        'posts_per_page' => $per_page,
105                        'paged'          => $page,
106                        'post_status'    => 'publish',
107                    );
108
109                    if ( ! empty( $input['search'] ) ) {
110                        $args['s'] = sanitize_text_field( $input['search'] );
111                    }
112
113                    if ( ! empty( $input['author'] ) ) {
114                        $args['author'] = intval( $input['author'] );
115                    }
116
117                    if ( ! empty( $input['status'] ) ) {
118                        $args['post_status'] = sanitize_text_field( $input['status'] );
119                    }
120
121                    $query   = new \WP_Query( $args );
122                    $results = array_map( 'blu_project_post_summary', $query->posts );
123
124                    return blu_prepare_ability_response(
125                        200,
126                        array(
127                            'post_type' => $resolved,
128                            'results'   => $results,
129                            'total'     => (int) $query->found_posts,
130                            'pages'     => (int) $query->max_num_pages,
131                            'page'      => (int) $page,
132                            'per_page'  => (int) $per_page,
133                        )
134                    );
135                },
136                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
137                'meta'                => array(
138                    'annotations' => array(
139                        'readonly'    => true,
140                        'destructive' => false,
141                        'idempotent'  => true,
142                    ),
143                ),
144            )
145        );
146
147        // Get custom post type
148        blu_register_ability(
149            'blu/get-cpt',
150            array(
151                'label'               => 'Get Custom Post Type Item',
152                'description'         => 'Get a single content item from a custom post type by its ID. Accepts friendly post_type identifiers (slug, REST base, or label).',
153                'category'            => 'blu-mcp',
154                'input_schema'        => array(
155                    'type'       => 'object',
156                    'properties' => array(
157                        'post_type' => array(
158                            'type'        => 'string',
159                            'description' => 'Post type identifier — slug, REST base, or label. Case-insensitive.',
160                        ),
161                        'id'        => array(
162                            'type'        => 'integer',
163                            'description' => 'Post ID',
164                        ),
165                    ),
166                    'required'   => array( 'post_type', 'id' ),
167                ),
168                'execute_callback'    => function ( $input ) {
169                    $resolved = blu_resolve_post_type( (string) $input['post_type'] );
170                    if ( null === $resolved ) {
171                        return blu_post_type_not_found_response( (string) $input['post_type'] );
172                    }
173
174                    $post = get_post( intval( $input['id'] ) );
175                    if ( ! $post || $post->post_type !== $resolved ) {
176                        return blu_prepare_ability_response(
177                            404,
178                            sprintf( 'No %s item found with ID %d.', $resolved, (int) $input['id'] )
179                        );
180                    }
181
182                    return blu_prepare_ability_response( 200, blu_project_post_full( $post ) );
183                },
184                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
185                'meta'                => array(
186                    'annotations' => array(
187                        'readonly'    => true,
188                        'destructive' => false,
189                        'idempotent'  => true,
190                    ),
191                ),
192            )
193        );
194
195        // Add custom post type
196        blu_register_ability(
197            'blu/add-cpt',
198            array(
199                'label'               => 'Add Custom Post Type Item',
200                'description'         => 'Create a new content item within an existing custom post type (e.g. add a new menu item, event, or recipe). Accepts friendly post_type identifiers (slug, REST base, or label). This does NOT register a new post type — use blu-list-post-types to discover what exists.',
201                'category'            => 'blu-mcp',
202                'input_schema'        => array(
203                    'type'       => 'object',
204                    'properties' => array(
205                        'post_type' => array(
206                            'type'        => 'string',
207                            'description' => 'Post type identifier — slug, REST base, or label. Case-insensitive. Must match a registered post type.',
208                        ),
209                        'title'     => array(
210                            'type'        => 'string',
211                            'description' => 'Post title',
212                        ),
213                        'content'   => array(
214                            'type'        => 'string',
215                            'description' => 'Post content in Gutenberg block format',
216                        ),
217                        'excerpt'   => array(
218                            'type'        => 'string',
219                            'description' => 'Post excerpt',
220                        ),
221                        'status'    => array(
222                            'type'        => 'string',
223                            'description' => 'Post status (e.g. "publish", "draft"). Defaults to "draft".',
224                        ),
225                    ),
226                    'required'   => array( 'post_type', 'title', 'content' ),
227                ),
228                'execute_callback'    => function ( $input ) {
229                    $resolved = blu_resolve_post_type( (string) $input['post_type'] );
230                    if ( null === $resolved ) {
231                        return blu_post_type_not_found_response( (string) $input['post_type'] );
232                    }
233
234                    $post_data = array(
235                        'post_type'    => $resolved,
236                        'post_title'   => sanitize_text_field( $input['title'] ),
237                        'post_content' => wp_kses_post( $input['content'] ),
238                        'post_status'  => 'draft',
239                    );
240
241                    if ( ! empty( $input['excerpt'] ) ) {
242                        $post_data['post_excerpt'] = sanitize_text_field( $input['excerpt'] );
243                    }
244
245                    if ( ! empty( $input['status'] ) ) {
246                        $post_data['post_status'] = sanitize_text_field( $input['status'] );
247                    }
248
249                    $post_id = wp_insert_post( $post_data, true );
250                    if ( is_wp_error( $post_id ) ) {
251                        return blu_prepare_ability_response( 500, $post_id->get_error_message() );
252                    }
253
254                    return blu_prepare_ability_response( 201, blu_project_post_full( get_post( $post_id ) ) );
255                },
256                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
257                'meta'                => array(
258                    'annotations' => array(
259                        'readonly'    => false,
260                        'destructive' => false,
261                        'idempotent'  => false,
262                    ),
263                ),
264            )
265        );
266
267        // Update custom post type
268        blu_register_ability(
269            'blu/update-cpt',
270            array(
271                'label'               => 'Update Custom Post Type Item',
272                'description'         => 'Update an existing content item in a custom post type by its ID. Accepts friendly post_type identifiers (slug, REST base, or label).',
273                'category'            => 'blu-mcp',
274                'input_schema'        => array(
275                    'type'       => 'object',
276                    'properties' => array(
277                        'post_type' => array(
278                            'type'        => 'string',
279                            'description' => 'Post type identifier — slug, REST base, or label. Case-insensitive.',
280                        ),
281                        'id'        => array(
282                            'type'        => 'integer',
283                            'description' => 'Post ID',
284                        ),
285                        'title'     => array(
286                            'type'        => 'string',
287                            'description' => 'Post title',
288                        ),
289                        'content'   => array(
290                            'type'        => 'string',
291                            'description' => 'Post content',
292                        ),
293                        'excerpt'   => array(
294                            'type'        => 'string',
295                            'description' => 'Post excerpt',
296                        ),
297                        'status'    => array(
298                            'type'        => 'string',
299                            'description' => 'Post status',
300                        ),
301                    ),
302                    'required'   => array( 'post_type', 'id' ),
303                ),
304                'execute_callback'    => function ( $input ) {
305                    $resolved = blu_resolve_post_type( (string) $input['post_type'] );
306                    if ( null === $resolved ) {
307                        return blu_post_type_not_found_response( (string) $input['post_type'] );
308                    }
309
310                    $post = get_post( intval( $input['id'] ) );
311                    if ( ! $post || $post->post_type !== $resolved ) {
312                        return blu_prepare_ability_response(
313                            404,
314                            sprintf( 'No %s item found with ID %d.', $resolved, (int) $input['id'] )
315                        );
316                    }
317
318                    $post_data = array( 'ID' => $post->ID );
319
320                    if ( ! empty( $input['title'] ) ) {
321                        $post_data['post_title'] = sanitize_text_field( $input['title'] );
322                    }
323
324                    if ( ! empty( $input['content'] ) ) {
325                        $post_data['post_content'] = wp_kses_post( $input['content'] );
326                    }
327
328                    if ( ! empty( $input['excerpt'] ) ) {
329                        $post_data['post_excerpt'] = sanitize_text_field( $input['excerpt'] );
330                    }
331
332                    if ( ! empty( $input['status'] ) ) {
333                        $post_data['post_status'] = sanitize_text_field( $input['status'] );
334                    }
335
336                    $post_id = wp_update_post( $post_data, true );
337                    if ( is_wp_error( $post_id ) ) {
338                        return blu_prepare_ability_response( 500, $post_id->get_error_message() );
339                    }
340
341                    return blu_prepare_ability_response( 200, blu_project_post_full( get_post( $post_id ) ) );
342                },
343                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
344                'meta'                => array(
345                    'annotations' => array(
346                        'readonly'    => false,
347                        'destructive' => false,
348                        'idempotent'  => true,
349                    ),
350                ),
351            )
352        );
353
354        // Delete custom post type
355        blu_register_ability(
356            'blu/delete-cpt',
357            array(
358                'label'               => 'Delete Custom Post Type Item',
359                'description'         => 'Permanently delete a content item from a custom post type by its ID. Accepts friendly post_type identifiers (slug, REST base, or label).',
360                'category'            => 'blu-mcp',
361                'input_schema'        => array(
362                    'type'       => 'object',
363                    'properties' => array(
364                        'post_type' => array(
365                            'type'        => 'string',
366                            'description' => 'Post type identifier — slug, REST base, or label. Case-insensitive.',
367                        ),
368                        'id'        => array(
369                            'type'        => 'integer',
370                            'description' => 'Post ID',
371                        ),
372                    ),
373                    'required'   => array( 'post_type', 'id' ),
374                ),
375                'execute_callback'    => function ( $input ) {
376                    $resolved = blu_resolve_post_type( (string) $input['post_type'] );
377                    if ( null === $resolved ) {
378                        return blu_post_type_not_found_response( (string) $input['post_type'] );
379                    }
380
381                    $post = get_post( intval( $input['id'] ) );
382                    if ( ! $post || $post->post_type !== $resolved ) {
383                        return blu_prepare_ability_response(
384                            404,
385                            sprintf( 'No %s item found with ID %d.', $resolved, (int) $input['id'] )
386                        );
387                    }
388
389                    $result = wp_delete_post( $post->ID, true );
390                    if ( ! $result ) {
391                        return blu_prepare_ability_response( 500, 'Failed to delete post with ID ' . $post->ID );
392                    }
393
394                    return blu_prepare_ability_response(
395                        200,
396                        array(
397                            'deleted'   => true,
398                            'id'        => (int) $post->ID,
399                            'post_type' => $resolved,
400                        )
401                    );
402                },
403                'permission_callback' => fn() => current_user_can( 'delete_posts' ),
404                'meta'                => array(
405                    'annotations' => array(
406                        'readonly'    => false,
407                        'destructive' => true,
408                        'idempotent'  => true,
409                    ),
410                ),
411            )
412        );
413    }
414}