PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.1
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-content.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-execute-php.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-readonly.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-content.php
2108 lines
1 <?php
2 /**
3 * Content (posts, pages, custom post types) MCP abilities.
4 *
5 * Registers Atarim/* abilities for reading and managing post objects of any
6 * post type — built-in (post, page) or custom. One ability per verb; the
7 * post_type parameter selects which type to operate on.
8 *
9 * Exposed abilities:
10 * atarim/list-post-types Discover available post type slugs.
11 * atarim/list-content Query posts with rich filters + pagination.
12 * atarim/get-content Read a single post with full body, taxonomies, meta.
13 * atarim/create-content Create a post/page/CPT item.
14 * atarim/update-content Update an existing post/page/CPT item.
15 * atarim/bulk-update-content Update one field across many posts in one call.
16 * atarim/delete-content Trash or permanently delete an item.
17 * atarim/list-revisions List revision history for a post.
18 * atarim/restore-revision Restore a post to a prior revision.
19 *
20 * Note: ability names registered here must also be added to the $tools array
21 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
22 * MCP server.
23 *
24 * @package atarim-visual-collaboration
25 */
26
27 if ( ! defined('ABSPATH') ) {
28 exit;
29 }
30
31 class AVCF_Abilities_Content extends AVCF_Abilities_Base {
32
33 /**
34 * Post types that must never be created or updated through the generic
35 * content abilities. These are internal / structural types WordPress
36 * stores as posts but which own dedicated write pipelines (Customizer,
37 * Site Editor, block/nav editors) that apply sanitisation and side
38 * effects the generic post write does not. Writing raw block markup into
39 * e.g. custom_css would store invalid CSS verbatim.
40 *
41 * @return string[]
42 */
43 private function avcf_write_protected_post_types() {
44 return [
45 'attachment', 'revision', 'nav_menu_item', 'custom_css',
46 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block',
47 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation',
48 ];
49 }
50
51 /**
52 * Redirect hint for a write-protected post type, so the refusal points the
53 * agent at the correct dedicated ability where one exists.
54 *
55 * @param string $post_type
56 * @return string
57 */
58 private function avcf_write_protected_hint( $post_type ) {
59 $map = [
60 'custom_css' => ' Use atarim/set-additional-css to change the theme Additional CSS.',
61 ];
62 return isset( $map[ $post_type ] ) ? $map[ $post_type ] : '';
63 }
64
65 /**
66 * Register all content abilities.
67 * Called from AVCF_MCP::avcf_mcp_register_abilities() on wp_abilities_api_init.
68 */
69 public function register() {
70
71 // ---- list-post-types ----
72 wp_register_ability( 'atarim/list-post-types', [
73 'label' => 'List Post Types',
74 'description' => 'Returns all available post types on the site, including built-in (post, page) and custom post types. Use the returned slugs as the post_type parameter for list-content and create-content.',
75 'category' => 'atarim',
76 'input_schema' => [
77 'type' => 'object',
78 'properties' => [
79 'public_only' => [
80 'type' => 'boolean',
81 'description' => 'If true, return only public post types. Defaults to true.',
82 'default' => true,
83 ],
84 ],
85 'additionalProperties' => false,
86 ],
87 'output_schema' => [
88 'type' => 'object',
89 'properties' => [
90 'total' => [ 'type' => 'integer' ],
91 'post_types' => [
92 'type' => 'array',
93 'items' => [
94 'type' => 'object',
95 'properties' => [
96 'slug' => [ 'type' => 'string' ],
97 'label' => [ 'type' => 'string' ],
98 'singular' => [ 'type' => 'string' ],
99 'description' => [ 'type' => 'string' ],
100 'public' => [ 'type' => 'boolean' ],
101 'hierarchical' => [ 'type' => 'boolean' ],
102 'built_in' => [ 'type' => 'boolean' ],
103 'supports' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
104 ],
105 ],
106 ],
107 ],
108 'required' => [ 'total', 'post_types' ],
109 ],
110 'execute_callback' => function( $input = [] ) {
111 $public_only = isset( $input['public_only'] ) ? (bool) $input['public_only'] : true;
112
113 $args = $public_only ? [ 'public' => true ] : [];
114 $types = get_post_types( $args, 'objects' );
115
116 // Exclude attachment by default — it's "public" but rarely what a caller means.
117 unset( $types['attachment'] );
118
119 $result = [];
120 foreach ( $types as $slug => $obj ) {
121 $result[] = [
122 'slug' => $slug,
123 'label' => isset( $obj->labels->name ) ? $obj->labels->name : ( isset( $obj->label ) ? $obj->label : $slug ),
124 'singular' => isset( $obj->labels->singular_name ) ? $obj->labels->singular_name : '',
125 'description' => isset( $obj->description ) ? $obj->description : '',
126 'public' => (bool) $obj->public,
127 'hierarchical' => (bool) $obj->hierarchical,
128 'built_in' => (bool) $obj->_builtin,
129 'supports' => array_keys( get_all_post_type_supports( $slug ) ),
130 ];
131 }
132
133 return [
134 'total' => count( $result ),
135 'post_types' => $result,
136 ];
137 },
138 'permission_callback' => function() {
139 return current_user_can( 'edit_posts' );
140 },
141 'meta' => [
142 'mcp' => [ 'public' => true, 'type' => 'tool' ],
143 'annotations' => [
144 'readonly' => true,
145 'destructive' => false,
146 'idempotent' => true,
147 ],
148 ],
149 ] );
150
151 // ---- list-content ----
152 wp_register_ability( 'atarim/list-content', [
153 'label' => 'List Content',
154 'description' => 'Query posts, pages, or custom post types with rich filters: status, author, date range, taxonomy, custom field (meta) key/value, free-text search, plus ordering and pagination. Each returned item includes title, slug, status, dates, excerpt, and a content_preview (first ~200 chars of body, HTML/blocks stripped). Pass include_content: true to also return full post bodies — use sparingly, response size grows. Use list-post-types to discover available post_type values.',
155 'category' => 'atarim',
156 'input_schema' => [
157 'type' => 'object',
158 'properties' => [
159 'post_type' => [
160 'type' => 'string',
161 'description' => 'Post type slug (e.g. "post", "page", "product"). Defaults to "post".',
162 'default' => 'post',
163 'minLength' => 1,
164 ],
165 'status' => [
166 'type' => [ 'string', 'array' ],
167 'description' => 'Filter by post status. Single value or array. Omit for all non-trashed.',
168 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future', 'trash' ],
169 ],
170 'author' => [
171 'type' => 'integer',
172 'description' => 'Filter by author user ID.',
173 'minimum' => 1,
174 ],
175 'date_from' => [
176 'type' => 'string',
177 'description' => 'Only items with publish date on or after this date. ISO 8601 or strtotime()-parseable.',
178 ],
179 'date_to' => [
180 'type' => 'string',
181 'description' => 'Only items with publish date on or before this date. ISO 8601 or strtotime()-parseable.',
182 ],
183 'search' => [
184 'type' => 'string',
185 'description' => 'Free-text keyword search across title and content (WordPress default search behaviour).',
186 'minLength' => 1,
187 ],
188 'taxonomy' => [
189 'type' => 'string',
190 'description' => 'Taxonomy slug to filter by (e.g. "category", "post_tag", or a custom taxonomy). Use with "terms" parameter. The taxonomy must apply to the post_type.',
191 'minLength' => 1,
192 ],
193 'terms' => [
194 'type' => 'array',
195 'description' => 'Array of term slugs to match (OR semantics — items with any of these terms). Requires "taxonomy".',
196 'items' => [ 'type' => 'string' ],
197 'minItems' => 1,
198 ],
199 'meta_key' => [
200 'type' => 'string',
201 'description' => 'Custom field key to filter by. Pair with meta_value and optionally meta_compare.',
202 'minLength' => 1,
203 ],
204 'meta_value' => [
205 'type' => 'string',
206 'description' => 'Custom field value to match.',
207 ],
208 'meta_compare' => [
209 'type' => 'string',
210 'description' => 'How to compare meta_value. Defaults to "=". Supports the safe subset of WP_Query meta_compare operators.',
211 'enum' => [ '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'EXISTS', 'NOT EXISTS' ],
212 'default' => '=',
213 ],
214 'orderby' => [
215 'type' => 'string',
216 'description' => 'Field to sort by.',
217 'enum' => [ 'date', 'modified', 'title', 'menu_order', 'ID', 'author', 'rand' ],
218 'default' => 'date',
219 ],
220 'order' => [
221 'type' => 'string',
222 'description' => 'Sort direction.',
223 'enum' => [ 'ASC', 'DESC' ],
224 'default' => 'DESC',
225 ],
226 'limit' => [
227 'type' => 'integer',
228 'description' => 'Max items per page. -1 returns all (use carefully). Defaults to 20.',
229 'default' => 20,
230 'minimum' => -1,
231 ],
232 'offset' => [
233 'type' => 'integer',
234 'description' => 'Skip this many items before returning results. For pagination.',
235 'default' => 0,
236 'minimum' => 0,
237 ],
238 'include_content' => [
239 'type' => 'boolean',
240 'description' => 'Include the full post body in each item under "content". Defaults to false — only a short content_preview is returned. Set true when the caller actually needs full bodies; responses can grow large.',
241 'default' => false,
242 ],
243 ],
244 'additionalProperties' => false,
245 ],
246 'output_schema' => [
247 'type' => 'object',
248 'properties' => [
249 'total' => [ 'type' => 'integer' ],
250 'returned' => [ 'type' => 'integer' ],
251 'offset' => [ 'type' => 'integer' ],
252 'post_type' => [ 'type' => 'string' ],
253 'items' => [
254 'type' => 'array',
255 'items' => [
256 'type' => 'object',
257 'properties' => [
258 'id' => [ 'type' => 'integer' ],
259 'title' => [ 'type' => 'string' ],
260 'slug' => [ 'type' => 'string' ],
261 'status' => [ 'type' => 'string' ],
262 'post_type' => [ 'type' => 'string' ],
263 'url' => [ 'type' => 'string' ],
264 'author' => [ 'type' => 'integer' ],
265 'parent' => [ 'type' => 'integer' ],
266 'date' => [ 'type' => 'string' ],
267 'created' => [ 'type' => 'string' ],
268 'modified' => [ 'type' => 'string' ],
269 'excerpt' => [ 'type' => 'string' ],
270 'content_preview' => [ 'type' => 'string' ],
271 'content' => [ 'type' => 'string' ],
272 ],
273 ],
274 ],
275 ],
276 'required' => [ 'total', 'returned', 'post_type', 'items' ],
277 ],
278 'execute_callback' => function( $input = [] ) {
279 $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : 'post';
280
281 if ( ! post_type_exists( $post_type ) ) {
282 return [
283 'total' => 0,
284 'returned' => 0,
285 'offset' => 0,
286 'post_type' => $post_type,
287 'items' => [],
288 ];
289 }
290
291 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
292 $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0;
293
294 $args = [
295 'post_type' => $post_type,
296 'posts_per_page' => $limit,
297 'offset' => $offset,
298 'orderby' => isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'date',
299 'order' => ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC',
300 // suppress_filters off so caching/translation plugins still apply.
301 ];
302
303 if ( ! empty( $input['status'] ) ) {
304 $args['post_status'] = $input['status'];
305 } else {
306 $args['post_status'] = [ 'publish', 'draft', 'pending', 'private', 'future' ];
307 }
308
309 if ( isset( $input['author'] ) ) {
310 $args['author'] = (int) $input['author'];
311 }
312
313 if ( isset( $input['search'] ) && $input['search'] !== '' ) {
314 $args['s'] = (string) $input['search'];
315 }
316
317 // Date range — WP_Query accepts a date_query array.
318 if ( isset( $input['date_from'] ) || isset( $input['date_to'] ) ) {
319 $date_query = [];
320 if ( isset( $input['date_from'] ) && $input['date_from'] !== '' ) {
321 list( $local_from, , $err_from ) = $this->avcf_normalize_post_date( (string) $input['date_from'] );
322 if ( $err_from !== null ) {
323 return [
324 'total' => 0,
325 'returned' => 0,
326 'offset' => $offset,
327 'post_type' => $post_type,
328 'items' => [],
329 'message' => 'date_from invalid: ' . $err_from,
330 ];
331 }
332 $date_query['after'] = $local_from;
333 }
334 if ( isset( $input['date_to'] ) && $input['date_to'] !== '' ) {
335 list( $local_to, , $err_to ) = $this->avcf_normalize_post_date( (string) $input['date_to'] );
336 if ( $err_to !== null ) {
337 return [
338 'total' => 0,
339 'returned' => 0,
340 'offset' => $offset,
341 'post_type' => $post_type,
342 'items' => [],
343 'message' => 'date_to invalid: ' . $err_to,
344 ];
345 }
346 $date_query['before'] = $local_to;
347 }
348 $date_query['inclusive'] = true;
349 $args['date_query'] = [ $date_query ];
350 }
351
352 // Taxonomy filter — single taxonomy + array of term slugs, OR semantics.
353 if ( ! empty( $input['taxonomy'] ) && ! empty( $input['terms'] ) ) {
354 $tax = sanitize_key( (string) $input['taxonomy'] );
355 if ( ! taxonomy_exists( $tax ) ) {
356 return [
357 'total' => 0,
358 'returned' => 0,
359 'offset' => $offset,
360 'post_type' => $post_type,
361 'items' => [],
362 'message' => sprintf( 'Taxonomy "%s" does not exist on this site.', $tax ),
363 ];
364 }
365 $terms = array_map( 'sanitize_title', (array) $input['terms'] );
366 $args['tax_query'] = [
367 [
368 'taxonomy' => $tax,
369 'field' => 'slug',
370 'terms' => $terms,
371 'operator' => 'IN',
372 ],
373 ];
374 }
375
376 // Meta filter — single key/value/compare. EXISTS / NOT EXISTS don't need a value.
377 if ( ! empty( $input['meta_key'] ) ) {
378 $compare = isset( $input['meta_compare'] ) ? (string) $input['meta_compare'] : '=';
379 $allowed_compare = [ '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'EXISTS', 'NOT EXISTS' ];
380 if ( ! in_array( $compare, $allowed_compare, true ) ) {
381 $compare = '=';
382 }
383 $meta_clause = [
384 'key' => (string) $input['meta_key'],
385 'compare' => $compare,
386 ];
387 if ( $compare !== 'EXISTS' && $compare !== 'NOT EXISTS' ) {
388 $meta_clause['value'] = isset( $input['meta_value'] ) ? (string) $input['meta_value'] : '';
389 }
390 $args['meta_query'] = [ $meta_clause ];
391 }
392
393 $include_content = ! empty( $input['include_content'] );
394 $query = new \WP_Query( $args );
395
396 $items = [];
397 foreach ( $query->posts as $post ) {
398 $excerpt = $post->post_excerpt;
399 if ( $excerpt === '' && $post->post_content !== '' ) {
400 // Generate a short excerpt from content when one isn't authored.
401 $excerpt = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 30, '' );
402 }
403
404 // content_preview: ~200 chars of plain text from the body, ellipsis if truncated.
405 $stripped = trim( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) );
406 $stripped = preg_replace( '/\s+/', ' ', $stripped );
407 $content_preview = ( strlen( $stripped ) > 200 )
408 ? substr( $stripped, 0, 200 ) . ''
409 : $stripped;
410
411 $item = [
412 'id' => $post->ID,
413 'title' => $post->post_title,
414 'slug' => $post->post_name,
415 'status' => $post->post_status,
416 'post_type' => $post->post_type,
417 'url' => get_permalink( $post->ID ),
418 'author' => (int) $post->post_author,
419 'parent' => (int) $post->post_parent,
420 'date' => $post->post_date,
421 'created' => $post->post_date_gmt,
422 'modified' => $post->post_modified_gmt,
423 'excerpt' => $excerpt,
424 'content_preview' => $content_preview,
425 ];
426
427 if ( $include_content ) {
428 $item['content'] = $post->post_content;
429 }
430
431 $items[] = $item;
432 }
433
434 return [
435 'total' => (int) $query->found_posts,
436 'returned' => count( $items ),
437 'offset' => $offset,
438 'post_type' => $post_type,
439 'items' => $items,
440 ];
441 },
442 'permission_callback' => function() {
443 return current_user_can( 'edit_posts' );
444 },
445 'meta' => [
446 'mcp' => [ 'public' => true, 'type' => 'tool' ],
447 'annotations' => [
448 'readonly' => true,
449 'destructive' => false,
450 'idempotent' => true,
451 ],
452 ],
453 ] );
454
455 // ---- get-content ----
456 wp_register_ability( 'atarim/get-content', [
457 'label' => 'Get Content',
458 'description' => 'Returns full detail for a single post, page, or custom post type item — including the post body, excerpt, dates, author, parent, featured image, comment/ping status. Optional include flags add taxonomies (categories, tags, custom taxonomies) and selected meta fields. Use list-content to discover IDs.',
459 'category' => 'atarim',
460 'input_schema' => [
461 'type' => 'object',
462 'properties' => [
463 'id' => [
464 'type' => 'integer',
465 'description' => 'Post ID.',
466 'minimum' => 1,
467 ],
468 'include_taxonomies' => [
469 'type' => 'boolean',
470 'description' => 'Include all assigned taxonomy terms (categories, tags, custom taxonomies) for the post.',
471 'default' => false,
472 ],
473 'include_meta_keys' => [
474 'type' => 'array',
475 'description' => 'List of specific meta keys to read. Omit or empty array to skip meta. Keys beginning with "_" (private/internal meta) are excluded for safety even if requested.',
476 'items' => [ 'type' => 'string' ],
477 ],
478 ],
479 'required' => [ 'id' ],
480 'additionalProperties' => false,
481 ],
482 'output_schema' => [
483 'type' => 'object',
484 'properties' => [
485 'success' => [ 'type' => 'boolean' ],
486 'id' => [ 'type' => 'integer' ],
487 'title' => [ 'type' => 'string' ],
488 'slug' => [ 'type' => 'string' ],
489 'status' => [ 'type' => 'string' ],
490 'post_type' => [ 'type' => 'string' ],
491 'url' => [ 'type' => 'string' ],
492 'content' => [ 'type' => 'string' ],
493 'excerpt' => [ 'type' => 'string' ],
494 'date' => [ 'type' => 'string' ],
495 'created' => [ 'type' => 'string' ],
496 'modified' => [ 'type' => 'string' ],
497 'author' => [ 'type' => 'integer' ],
498 'parent' => [ 'type' => 'integer' ],
499 'featured_media' => [ 'type' => 'integer' ],
500 'comment_status' => [ 'type' => 'string' ],
501 'ping_status' => [ 'type' => 'string' ],
502 'taxonomies' => [ 'type' => 'object' ],
503 'meta' => [ 'type' => 'object' ],
504 'message' => [ 'type' => 'string' ],
505 ],
506 'required' => [ 'success' ],
507 ],
508 'execute_callback' => function( $input = [] ) {
509 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
510 if ( $id <= 0 ) {
511 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
512 }
513
514 $post = get_post( $id );
515 if ( ! $post ) {
516 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $id ) ];
517 }
518
519 $pt_obj = get_post_type_object( $post->post_type );
520 if ( $pt_obj && ! current_user_can( $pt_obj->cap->read_post, $id ) ) {
521 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to read this %s.', $post->post_type ) ];
522 }
523
524 $result = [
525 'success' => true,
526 'id' => $post->ID,
527 'title' => $post->post_title,
528 'slug' => $post->post_name,
529 'status' => $post->post_status,
530 'post_type' => $post->post_type,
531 'url' => get_permalink( $post->ID ),
532 'content' => $post->post_content,
533 'excerpt' => $post->post_excerpt,
534 'date' => $post->post_date,
535 'created' => $post->post_date_gmt,
536 'modified' => $post->post_modified_gmt,
537 'author' => (int) $post->post_author,
538 'parent' => (int) $post->post_parent,
539 'featured_media' => (int) get_post_thumbnail_id( $post->ID ),
540 'comment_status' => $post->comment_status,
541 'ping_status' => $post->ping_status,
542 'message' => 'OK.',
543 ];
544
545 if ( ! empty( $input['include_taxonomies'] ) ) {
546 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
547 $tax_assignments = [];
548 foreach ( $taxonomies as $tax ) {
549 $terms = get_the_terms( $post->ID, $tax );
550 if ( is_wp_error( $terms ) || empty( $terms ) ) {
551 $tax_assignments[ $tax ] = [];
552 continue;
553 }
554 $tax_assignments[ $tax ] = array_map(
555 function( $t ) {
556 return [
557 'term_id' => (int) $t->term_id,
558 'slug' => $t->slug,
559 'name' => $t->name,
560 ];
561 },
562 $terms
563 );
564 }
565 $result['taxonomies'] = $tax_assignments;
566 }
567
568 if ( ! empty( $input['include_meta_keys'] ) && is_array( $input['include_meta_keys'] ) ) {
569 $meta = [];
570 foreach ( $input['include_meta_keys'] as $key ) {
571 $key = (string) $key;
572 // Skip private/internal meta even if explicitly requested.
573 if ( $key === '' || strpos( $key, '_' ) === 0 ) {
574 continue;
575 }
576 $value = get_post_meta( $post->ID, $key, true );
577 $meta[ $key ] = $value;
578 }
579 $result['meta'] = $meta;
580 }
581
582 return $result;
583 },
584 'permission_callback' => function() {
585 return current_user_can( 'edit_posts' );
586 },
587 'meta' => [
588 'mcp' => [ 'public' => true, 'type' => 'tool' ],
589 'annotations' => [
590 'readonly' => true,
591 'destructive' => false,
592 'idempotent' => true,
593 ],
594 ],
595 ] );
596
597 // ---- create-content ----
598 wp_register_ability( 'atarim/create-content', [
599 'label' => 'Create Content',
600 'description' => 'Creates a new post, page, or custom post type item. Required: post_type and title. All other fields are optional — WordPress auto-generates the slug from the title if omitted, and status defaults to "draft". The content body can be supplied inline (content — plain text, raw HTML, or Gutenberg block markup) or pulled from a URL (content_url); see content_format to control processing. Use list-post-types to discover available post_type values.',
601 'category' => 'atarim',
602 'input_schema' => [
603 'type' => 'object',
604 'properties' => [
605 'post_type' => [
606 'type' => 'string',
607 'description' => 'Post type slug (e.g. "post", "page", or a custom slug).',
608 'minLength' => 1,
609 ],
610 'title' => [
611 'type' => 'string',
612 'description' => 'Post title.',
613 'minLength' => 1,
614 ],
615 'slug' => [
616 'type' => 'string',
617 'description' => 'Desired URL slug. Omit to let WordPress generate one from the title. WordPress auto-suffixes on conflict (slug-2, slug-3).',
618 'minLength' => 1,
619 ],
620 'status' => [
621 'type' => 'string',
622 'description' => 'Publish status. Defaults to "draft". Use "future" together with a date in the future to schedule.',
623 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
624 'default' => 'draft',
625 ],
626 'content' => [
627 'type' => 'string',
628 'description' => 'Post body as an inline string. Plain text, raw HTML, or Gutenberg block markup. See content_format to control processing. Mutually exclusive with content_url — provide one, not both.',
629 ],
630 'content_url' => [
631 'type' => 'string',
632 'description' => 'Alternative to content: a URL to pull the post body from. The response body is fetched verbatim (HTML, PHP source, plain text, or block markup — no sanitisation) and then processed per content_format; use content_format:"raw" to store it byte-for-byte. Must be a publicly reachable http/https URL — requests to private/loopback addresses are rejected. Mutually exclusive with content.',
633 ],
634 'content_format' => [
635 'type' => 'string',
636 'description' => 'How to process the content (or fetched content_url) body. "auto" (default): detect block delimiters and pass through if present, otherwise wrap paragraphs as wp:paragraph blocks so the result stays editable in the block editor. "raw": store content exactly as provided, no processing (use this for HTML/PHP/other file content that must not be altered). "blocks": caller asserts content is already valid block markup; pass through with no detection.',
637 'enum' => [ 'auto', 'raw', 'blocks' ],
638 'default' => 'auto',
639 ],
640 'excerpt' => [
641 'type' => 'string',
642 'description' => 'Hand-written excerpt. Omit to let WordPress generate one from the content.',
643 ],
644 'date' => [
645 'type' => 'string',
646 'description' => 'Publish date in site-local time. Accepts ISO 8601 (2026-05-22T14:30:00) or any strtotime()-parseable string. For status="future", must be in the future.',
647 ],
648 'author' => [
649 'type' => 'integer',
650 'description' => 'User ID of the post author. Defaults to the current user. Setting this to another user requires edit_others_posts capability for the post type.',
651 'minimum' => 1,
652 ],
653 'parent' => [
654 'type' => 'integer',
655 'description' => 'Parent post ID for hierarchical post types (pages, custom hierarchical CPTs). 0 means no parent.',
656 'minimum' => 0,
657 ],
658 'featured_media' => [
659 'type' => 'integer',
660 'description' => 'Attachment ID to use as the featured image. The attachment must already exist in the media library and be an image (not a PDF, video, or audio file).',
661 'minimum' => 1,
662 ],
663 'comment_status' => [
664 'type' => 'string',
665 'description' => 'Whether comments are allowed. Defaults to the site-wide setting.',
666 'enum' => [ 'open', 'closed' ],
667 ],
668 'ping_status' => [
669 'type' => 'string',
670 'description' => 'Whether pingbacks and trackbacks are allowed. Defaults to the site-wide setting.',
671 'enum' => [ 'open', 'closed' ],
672 ],
673 'meta' => [
674 'type' => 'object',
675 'description' => 'Optional map of post-meta keys to values, applied atomically at creation so you do not need follow-up write calls. Values are stored slash-safe, so JSON meta such as Elementor\'s _elementor_data round-trips intact. Underscore-prefixed keys (e.g. _elementor_data, _elementor_edit_mode, _elementor_template_type, _wp_page_template) are allowed. For framework fields (ACF / Toolset / Meta Box) that need their own write hooks, use update-post-field after creation instead.',
676 'additionalProperties' => true,
677 ],
678 ],
679 'required' => [ 'post_type', 'title' ],
680 'additionalProperties' => false,
681 ],
682 'output_schema' => [
683 'type' => 'object',
684 'properties' => [
685 'success' => [ 'type' => 'boolean' ],
686 'id' => [ 'type' => 'integer' ],
687 'title' => [ 'type' => 'string' ],
688 'slug' => [ 'type' => 'string' ],
689 'status' => [ 'type' => 'string' ],
690 'post_type' => [ 'type' => 'string' ],
691 'url' => [ 'type' => 'string' ],
692 'excerpt' => [ 'type' => 'string' ],
693 'date' => [ 'type' => 'string' ],
694 'author' => [ 'type' => 'integer' ],
695 'parent' => [ 'type' => 'integer' ],
696 'featured_media' => [ 'type' => 'integer' ],
697 'comment_status' => [ 'type' => 'string' ],
698 'ping_status' => [ 'type' => 'string' ],
699 'message' => [ 'type' => 'string' ],
700 ],
701 'required' => [ 'success', 'message' ],
702 ],
703 'execute_callback' => function( $input = [] ) {
704 $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : '';
705 $title = isset( $input['title'] ) ? sanitize_text_field( $input['title'] ) : '';
706
707 if ( empty( $post_type ) || empty( $title ) ) {
708 return [
709 'success' => false,
710 'message' => 'post_type and title are required.',
711 ];
712 }
713
714 if ( ! post_type_exists( $post_type ) ) {
715 return [
716 'success' => false,
717 'message' => sprintf( 'Post type "%s" does not exist on this site.', $post_type ),
718 ];
719 }
720
721 if ( in_array( $post_type, $this->avcf_write_protected_post_types(), true ) ) {
722 return [
723 'success' => false,
724 'message' => sprintf(
725 'Post type "%s" cannot be created through this ability (internal/structural type).%s',
726 $post_type,
727 $this->avcf_write_protected_hint( $post_type )
728 ),
729 ];
730 }
731
732 $pt_obj = get_post_type_object( $post_type );
733 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_posts ) ) {
734 return [
735 'success' => false,
736 'message' => sprintf( 'You do not have permission to create %s.', $post_type ),
737 ];
738 }
739
740 // Status — default to draft, validate enum.
741 $allowed_statuses = [ 'publish', 'draft', 'pending', 'private', 'future' ];
742 $status = isset( $input['status'] ) ? sanitize_key( $input['status'] ) : 'draft';
743 if ( ! in_array( $status, $allowed_statuses, true ) ) {
744 $status = 'draft';
745 }
746
747 // Publishing requires the publish capability for this post type.
748 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
749 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
750 return [
751 'success' => false,
752 'message' => sprintf( 'You do not have permission to publish %s.', $post_type ),
753 ];
754 }
755 }
756
757 $postarr = [
758 'post_type' => $post_type,
759 'post_title' => $title,
760 'post_status' => $status,
761 ];
762
763 // Slug — optional; let wp_insert_post auto-generate from title if omitted.
764 if ( isset( $input['slug'] ) && $input['slug'] !== '' ) {
765 $postarr['post_name'] = sanitize_title( $input['slug'] );
766 }
767
768 // Content body — inline (content) or pulled from a URL (content_url).
769 $has_content = array_key_exists( 'content', $input );
770 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
771 if ( $has_content && $has_content_url ) {
772 return [
773 'success' => false,
774 'message' => 'Provide either content or content_url, not both.',
775 ];
776 }
777 if ( $has_content || $has_content_url ) {
778 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
779 if ( $has_content_url ) {
780 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
781 if ( $fetch_err !== null ) {
782 return [ 'success' => false, 'message' => $fetch_err ];
783 }
784 $raw_content = $fetched_content;
785 } else {
786 $raw_content = (string) $input['content'];
787 }
788 $postarr['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
789 }
790
791 // Excerpt.
792 if ( isset( $input['excerpt'] ) ) {
793 $postarr['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
794 }
795
796 // Date — parse, normalise, validate "future" constraint.
797 if ( isset( $input['date'] ) && $input['date'] !== '' ) {
798 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
799 if ( $date_err !== null ) {
800 return [ 'success' => false, 'message' => $date_err ];
801 }
802 if ( $status === 'future' && strtotime( $gmt_date ) <= time() ) {
803 return [
804 'success' => false,
805 'message' => 'status="future" requires a date in the future.',
806 ];
807 }
808 $postarr['post_date'] = $local_date;
809 $postarr['post_date_gmt'] = $gmt_date;
810 }
811
812 // Author — defaults to current user; requires edit_others_posts to set someone else.
813 if ( isset( $input['author'] ) ) {
814 $author_id = (int) $input['author'];
815 if ( $author_id <= 0 ) {
816 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
817 }
818 if ( ! get_userdata( $author_id ) ) {
819 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
820 }
821 if ( $author_id !== get_current_user_id() ) {
822 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
823 return [
824 'success' => false,
825 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post_type ),
826 ];
827 }
828 }
829 $postarr['post_author'] = $author_id;
830 }
831
832 // Parent — validate hierarchical and that the parent exists.
833 if ( isset( $input['parent'] ) ) {
834 $parent_id = (int) $input['parent'];
835 if ( $parent_id < 0 ) {
836 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
837 }
838 if ( $parent_id > 0 ) {
839 if ( ! is_post_type_hierarchical( $post_type ) ) {
840 return [
841 'success' => false,
842 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post_type ),
843 ];
844 }
845 $parent_post = get_post( $parent_id );
846 if ( ! $parent_post || $parent_post->post_type !== $post_type ) {
847 return [
848 'success' => false,
849 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post_type ),
850 ];
851 }
852 }
853 $postarr['post_parent'] = $parent_id;
854 }
855
856 // Featured media — validate existence + is-image. Stored via _thumbnail_id meta after insert.
857 $featured_media_id = null;
858 if ( isset( $input['featured_media'] ) ) {
859 $featured_media_id = (int) $input['featured_media'];
860 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
861 if ( $attach_err !== null ) {
862 return [ 'success' => false, 'message' => $attach_err ];
863 }
864 }
865
866 // Comment / ping status.
867 if ( isset( $input['comment_status'] ) ) {
868 $cs = sanitize_key( $input['comment_status'] );
869 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
870 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
871 }
872 $postarr['comment_status'] = $cs;
873 }
874 if ( isset( $input['ping_status'] ) ) {
875 $ps = sanitize_key( $input['ping_status'] );
876 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
877 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
878 }
879 $postarr['ping_status'] = $ps;
880 }
881
882 $post_id = wp_insert_post( $postarr, true );
883
884 if ( is_wp_error( $post_id ) ) {
885 return [
886 'success' => false,
887 'message' => 'Creation failed: ' . $post_id->get_error_message(),
888 ];
889 }
890
891 // Set featured image after insert (wp_insert_post does not accept _thumbnail_id directly).
892 if ( $featured_media_id !== null ) {
893 set_post_thumbnail( $post_id, $featured_media_id );
894 }
895
896 // Apply an optional meta map atomically at creation. Written via
897 // update_post_meta with wp_slash so values (e.g. the _elementor_data
898 // JSON blob) round-trip intact — update_metadata unslashes internally.
899 $meta_written = [];
900 if ( isset( $input['meta'] ) && ( is_array( $input['meta'] ) || is_object( $input['meta'] ) ) ) {
901 foreach ( (array) $input['meta'] as $mk => $mv ) {
902 $mk = (string) $mk;
903 if ( $mk === '' ) { continue; }
904 update_post_meta( $post_id, $mk, wp_slash( $mv ) );
905 $meta_written[] = $mk;
906 }
907 }
908
909 $post = get_post( $post_id );
910
911 // Body write-receipt (see update-content): confirm the stored body
912 // without echoing it. content_verified compares intended vs stored.
913 $stored_body = (string) $post->post_content;
914 $content_receipt = [
915 'content_bytes' => strlen( $stored_body ),
916 'content_sha1' => sha1( $stored_body ),
917 ];
918 if ( array_key_exists( 'post_content', $postarr ) ) {
919 $content_receipt['content_verified'] = ( sha1( (string) $postarr['post_content'] ) === sha1( $stored_body ) );
920 }
921
922 return array_merge( [
923 'success' => true,
924 'id' => $post_id,
925 'title' => $post->post_title,
926 'slug' => $post->post_name,
927 'status' => $post->post_status,
928 'post_type' => $post->post_type,
929 'url' => get_permalink( $post_id ),
930 'excerpt' => $post->post_excerpt,
931 'date' => $post->post_date,
932 'author' => (int) $post->post_author,
933 'parent' => (int) $post->post_parent,
934 'featured_media' => (int) get_post_thumbnail_id( $post_id ),
935 'comment_status' => $post->comment_status,
936 'ping_status' => $post->ping_status,
937 'meta_written' => $meta_written,
938 ], $content_receipt, [
939 'message' => empty( $meta_written ) ? 'Content created.' : sprintf( 'Content created; %d meta key(s) set: %s.', count( $meta_written ), implode( ', ', $meta_written ) ),
940 ] );
941 },
942 'permission_callback' => function() {
943 return current_user_can( 'edit_posts' );
944 },
945 'meta' => [
946 'mcp' => [ 'public' => true, 'type' => 'tool' ],
947 'annotations' => [
948 'readonly' => false,
949 'destructive' => false,
950 'idempotent' => false,
951 ],
952 ],
953 ] );
954
955 // ---- update-content ----
956 wp_register_ability( 'atarim/update-content', [
957 'label' => 'Update Content',
958 'description' => 'Updates an existing post, page, or custom post type item. Only the id is required; pass any subset of the other fields to update those. Omitted fields are left unchanged. The content body can be supplied inline (content — plain text, raw HTML, or Gutenberg block markup) or pulled from a URL (content_url); see content_format.',
959 'category' => 'atarim',
960 'input_schema' => [
961 'type' => 'object',
962 'properties' => [
963 'id' => [
964 'type' => 'integer',
965 'description' => 'Post ID. Required.',
966 'minimum' => 1,
967 ],
968 'title' => [
969 'type' => 'string',
970 'description' => 'New title. Omit to leave unchanged.',
971 'minLength' => 1,
972 ],
973 'slug' => [
974 'type' => 'string',
975 'description' => 'New URL slug. WordPress auto-suffixes on conflict. Omit to leave unchanged.',
976 'minLength' => 1,
977 ],
978 'status' => [
979 'type' => 'string',
980 'description' => 'New publish status. Use "future" with a future-dated "date" to schedule. Omit to leave unchanged.',
981 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
982 ],
983 'content' => [
984 'type' => 'string',
985 'description' => 'New post body as an inline string. Plain text, raw HTML, or Gutenberg block markup. See content_format. Pass an empty string to clear the body. Omit to leave unchanged. Mutually exclusive with content_url.',
986 ],
987 'content_url' => [
988 'type' => 'string',
989 'description' => 'Alternative to content: a URL to pull the new post body from. The response body is fetched verbatim (HTML, PHP source, plain text, or block markup — no sanitisation) and then processed per content_format; use content_format:"raw" to store it byte-for-byte. Must be a publicly reachable http/https URL — requests to private/loopback addresses are rejected. Mutually exclusive with content.',
990 ],
991 'content_format' => [
992 'type' => 'string',
993 'description' => 'How to process the content (or fetched content_url) body. "auto" (default), "raw", or "blocks". See create-content for details.',
994 'enum' => [ 'auto', 'raw', 'blocks' ],
995 'default' => 'auto',
996 ],
997 'excerpt' => [
998 'type' => 'string',
999 'description' => 'New excerpt. Pass an empty string to clear. Omit to leave unchanged.',
1000 ],
1001 'date' => [
1002 'type' => 'string',
1003 'description' => 'New publish date in site-local time. ISO 8601 or strtotime()-parseable. Omit to leave unchanged.',
1004 ],
1005 'author' => [
1006 'type' => 'integer',
1007 'description' => 'New author user ID. Requires edit_others_posts capability if different from current author. Omit to leave unchanged.',
1008 'minimum' => 1,
1009 ],
1010 'parent' => [
1011 'type' => 'integer',
1012 'description' => 'New parent post ID for hierarchical post types. 0 removes the parent. Omit to leave unchanged.',
1013 'minimum' => 0,
1014 ],
1015 'featured_media' => [
1016 'type' => 'integer',
1017 'description' => 'New featured image attachment ID. Must exist and be an image. Pass 0 to remove the featured image. Omit to leave unchanged.',
1018 'minimum' => 0,
1019 ],
1020 'comment_status' => [
1021 'type' => 'string',
1022 'description' => 'New comment status. Omit to leave unchanged.',
1023 'enum' => [ 'open', 'closed' ],
1024 ],
1025 'ping_status' => [
1026 'type' => 'string',
1027 'description' => 'New pingback/trackback status. Omit to leave unchanged.',
1028 'enum' => [ 'open', 'closed' ],
1029 ],
1030 ],
1031 'required' => [ 'id' ],
1032 'additionalProperties' => false,
1033 ],
1034 'output_schema' => [
1035 'type' => 'object',
1036 'properties' => [
1037 'success' => [ 'type' => 'boolean' ],
1038 'id' => [ 'type' => 'integer' ],
1039 'title' => [ 'type' => 'string' ],
1040 'slug' => [ 'type' => 'string' ],
1041 'status' => [ 'type' => 'string' ],
1042 'post_type' => [ 'type' => 'string' ],
1043 'url' => [ 'type' => 'string' ],
1044 'excerpt' => [ 'type' => 'string' ],
1045 'date' => [ 'type' => 'string' ],
1046 'author' => [ 'type' => 'integer' ],
1047 'parent' => [ 'type' => 'integer' ],
1048 'featured_media' => [ 'type' => 'integer' ],
1049 'comment_status' => [ 'type' => 'string' ],
1050 'ping_status' => [ 'type' => 'string' ],
1051 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
1052 'message' => [ 'type' => 'string' ],
1053 ],
1054 'required' => [ 'success', 'message' ],
1055 ],
1056 'execute_callback' => function( $input = [] ) {
1057 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1058 if ( $id <= 0 ) {
1059 return [
1060 'success' => false,
1061 'message' => 'id is required and must be a positive integer.',
1062 ];
1063 }
1064
1065 $post = get_post( $id );
1066 if ( ! $post ) {
1067 return [
1068 'success' => false,
1069 'message' => sprintf( 'Post %d not found.', $id ),
1070 ];
1071 }
1072
1073 if ( in_array( $post->post_type, $this->avcf_write_protected_post_types(), true ) ) {
1074 return [
1075 'success' => false,
1076 'message' => sprintf(
1077 'Post type "%s" cannot be edited through this ability (internal/structural type).%s',
1078 $post->post_type,
1079 $this->avcf_write_protected_hint( $post->post_type )
1080 ),
1081 ];
1082 }
1083
1084 $pt_obj = get_post_type_object( $post->post_type );
1085 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1086 return [
1087 'success' => false,
1088 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ),
1089 ];
1090 }
1091
1092 // Build the update payload from only the fields the caller actually sent.
1093 // wp_update_post leaves omitted fields untouched, but we track what we
1094 // changed so we can return a useful `updated` array.
1095 $update = [ 'ID' => $id ];
1096 $updated = [];
1097
1098 if ( array_key_exists( 'title', $input ) ) {
1099 $update['post_title'] = sanitize_text_field( (string) $input['title'] );
1100 $updated[] = 'title';
1101 }
1102
1103 if ( array_key_exists( 'slug', $input ) ) {
1104 $update['post_name'] = sanitize_title( (string) $input['slug'] );
1105 $updated[] = 'slug';
1106 }
1107
1108 // Status — needs publish_posts cap if moving into publish/future/private.
1109 if ( array_key_exists( 'status', $input ) ) {
1110 $status = sanitize_key( (string) $input['status'] );
1111 $allowed = [ 'publish', 'draft', 'pending', 'private', 'future' ];
1112 if ( ! in_array( $status, $allowed, true ) ) {
1113 return [
1114 'success' => false,
1115 'message' => sprintf( 'Invalid status "%s". Allowed: publish, draft, pending, private, future.', $status ),
1116 ];
1117 }
1118 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
1119 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1120 return [
1121 'success' => false,
1122 'message' => sprintf( 'You do not have permission to publish %s.', $post->post_type ),
1123 ];
1124 }
1125 }
1126 $update['post_status'] = $status;
1127 $updated[] = 'status';
1128 }
1129
1130 // Content body — inline (content) or pulled from a URL (content_url).
1131 $has_content = array_key_exists( 'content', $input );
1132 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
1133 if ( $has_content && $has_content_url ) {
1134 return [
1135 'success' => false,
1136 'message' => 'Provide either content or content_url, not both.',
1137 ];
1138 }
1139 if ( $has_content || $has_content_url ) {
1140 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
1141 if ( $has_content_url ) {
1142 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
1143 if ( $fetch_err !== null ) {
1144 return [ 'success' => false, 'message' => $fetch_err ];
1145 }
1146 $raw_content = $fetched_content;
1147 } else {
1148 $raw_content = (string) $input['content'];
1149 }
1150 $update['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
1151 $updated[] = 'content';
1152 }
1153
1154 if ( array_key_exists( 'excerpt', $input ) ) {
1155 $update['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
1156 $updated[] = 'excerpt';
1157 }
1158
1159 if ( array_key_exists( 'date', $input ) && $input['date'] !== '' ) {
1160 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
1161 if ( $date_err !== null ) {
1162 return [ 'success' => false, 'message' => $date_err ];
1163 }
1164 // If status is being set to "future" in this same call, validate date is in the future.
1165 $effective_status = isset( $update['post_status'] ) ? $update['post_status'] : $post->post_status;
1166 if ( $effective_status === 'future' && strtotime( $gmt_date ) <= time() ) {
1167 return [
1168 'success' => false,
1169 'message' => 'status="future" requires a date in the future.',
1170 ];
1171 }
1172 $update['post_date'] = $local_date;
1173 $update['post_date_gmt'] = $gmt_date;
1174 $updated[] = 'date';
1175 }
1176
1177 if ( array_key_exists( 'author', $input ) ) {
1178 $author_id = (int) $input['author'];
1179 if ( $author_id <= 0 ) {
1180 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
1181 }
1182 if ( ! get_userdata( $author_id ) ) {
1183 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
1184 }
1185 if ( $author_id !== get_current_user_id() ) {
1186 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1187 return [
1188 'success' => false,
1189 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post->post_type ),
1190 ];
1191 }
1192 }
1193 $update['post_author'] = $author_id;
1194 $updated[] = 'author';
1195 }
1196
1197 if ( array_key_exists( 'parent', $input ) ) {
1198 $parent_id = (int) $input['parent'];
1199 if ( $parent_id < 0 ) {
1200 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
1201 }
1202 if ( $parent_id > 0 ) {
1203 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1204 return [
1205 'success' => false,
1206 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post->post_type ),
1207 ];
1208 }
1209 if ( $parent_id === $id ) {
1210 return [ 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1211 }
1212 $parent_post = get_post( $parent_id );
1213 if ( ! $parent_post || $parent_post->post_type !== $post->post_type ) {
1214 return [
1215 'success' => false,
1216 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post->post_type ),
1217 ];
1218 }
1219 }
1220 $update['post_parent'] = $parent_id;
1221 $updated[] = 'parent';
1222 }
1223
1224 // Featured media — handled separately (post-insert via set_post_thumbnail).
1225 // 0 = remove the featured image, positive = validate and set.
1226 $featured_media_change = null; // null = unchanged, 0 = remove, >0 = set
1227 if ( array_key_exists( 'featured_media', $input ) ) {
1228 $featured_media_id = (int) $input['featured_media'];
1229 if ( $featured_media_id < 0 ) {
1230 return [ 'success' => false, 'message' => 'featured_media must be 0 or a positive attachment ID.' ];
1231 }
1232 if ( $featured_media_id > 0 ) {
1233 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
1234 if ( $attach_err !== null ) {
1235 return [ 'success' => false, 'message' => $attach_err ];
1236 }
1237 }
1238 $featured_media_change = $featured_media_id;
1239 $updated[] = 'featured_media';
1240 }
1241
1242 if ( array_key_exists( 'comment_status', $input ) ) {
1243 $cs = sanitize_key( (string) $input['comment_status'] );
1244 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
1245 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
1246 }
1247 $update['comment_status'] = $cs;
1248 $updated[] = 'comment_status';
1249 }
1250
1251 if ( array_key_exists( 'ping_status', $input ) ) {
1252 $ps = sanitize_key( (string) $input['ping_status'] );
1253 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
1254 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
1255 }
1256 $update['ping_status'] = $ps;
1257 $updated[] = 'ping_status';
1258 }
1259
1260 // Nothing actually changed (only ID was passed, and no featured_media change).
1261 if ( count( $update ) === 1 && $featured_media_change === null ) {
1262 return [
1263 'success' => false,
1264 'message' => 'No fields provided to update. Pass at least one field to change.',
1265 ];
1266 }
1267
1268 // Only call wp_update_post if there's something in the post table to update.
1269 if ( count( $update ) > 1 ) {
1270 $result = wp_update_post( $update, true );
1271 if ( is_wp_error( $result ) ) {
1272 return [
1273 'success' => false,
1274 'message' => 'Update failed: ' . $result->get_error_message(),
1275 ];
1276 }
1277 }
1278
1279 // Apply featured image change (separately because wp_update_post does not handle _thumbnail_id).
1280 if ( $featured_media_change !== null ) {
1281 if ( $featured_media_change === 0 ) {
1282 delete_post_thumbnail( $id );
1283 } else {
1284 set_post_thumbnail( $id, $featured_media_change );
1285 }
1286 }
1287
1288 $fresh = get_post( $id );
1289
1290 // Body write-receipt: confirm the stored content without a separate
1291 // (potentially very large) read. content_verified compares what we
1292 // intended to store against what is actually stored, byte-for-byte.
1293 $stored_body = (string) $fresh->post_content;
1294 $content_receipt = [
1295 'content_bytes' => strlen( $stored_body ),
1296 'content_sha1' => sha1( $stored_body ),
1297 'content_changed' => ( sha1( (string) $post->post_content ) !== sha1( $stored_body ) ),
1298 ];
1299 if ( array_key_exists( 'post_content', $update ) ) {
1300 $content_receipt['content_verified'] = ( sha1( (string) $update['post_content'] ) === sha1( $stored_body ) );
1301 }
1302
1303 return array_merge( [
1304 'success' => true,
1305 'id' => $id,
1306 'title' => $fresh->post_title,
1307 'slug' => $fresh->post_name,
1308 'status' => $fresh->post_status,
1309 'post_type' => $fresh->post_type,
1310 'url' => get_permalink( $id ),
1311 'excerpt' => $fresh->post_excerpt,
1312 'date' => $fresh->post_date,
1313 'author' => (int) $fresh->post_author,
1314 'parent' => (int) $fresh->post_parent,
1315 'featured_media' => (int) get_post_thumbnail_id( $id ),
1316 'comment_status' => $fresh->comment_status,
1317 'ping_status' => $fresh->ping_status,
1318 'updated' => $updated,
1319 ], $content_receipt, [
1320 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ),
1321 ] );
1322 },
1323 'permission_callback' => function() {
1324 return current_user_can( 'edit_posts' );
1325 },
1326 'meta' => [
1327 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1328 'annotations' => [
1329 'readonly' => false,
1330 'destructive' => false,
1331 'idempotent' => true,
1332 ],
1333 ],
1334 ] );
1335
1336 // ---- bulk-update-content ----
1337 wp_register_ability( 'atarim/bulk-update-content', [
1338 'label' => 'Bulk Update Content',
1339 'description' => 'Updates one field across many posts in a single call. Designed for sweeping changes — moving many drafts to published, reassigning posts to a new author after a user leaves, closing comments across a batch. Mixed-field-per-id updates are not supported by design; use update-content in a loop for those. Returns per-id success/failure tracking so partial failures (e.g. capability checks) don\'t mask the rest.',
1340 'category' => 'atarim',
1341 'input_schema' => [
1342 'type' => 'object',
1343 'properties' => [
1344 'ids' => [
1345 'type' => 'array',
1346 'description' => 'Post IDs to update.',
1347 'items' => [ 'type' => 'integer', 'minimum' => 1 ],
1348 'minItems' => 1,
1349 'maxItems' => 500,
1350 ],
1351 'field' => [
1352 'type' => 'string',
1353 'description' => 'Which field to update on every targeted post. Bulk operations are limited to fields that make sense applied uniformly — status, author, parent, comment_status, ping_status. Use update-content for per-post fields like title or content.',
1354 'enum' => [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ],
1355 ],
1356 'value' => [
1357 'description' => 'The new value for the chosen field. Type depends on the field: string for status / comment_status / ping_status, integer for author / parent.',
1358 ],
1359 ],
1360 'required' => [ 'ids', 'field', 'value' ],
1361 'additionalProperties' => false,
1362 ],
1363 'output_schema' => [
1364 'type' => 'object',
1365 'properties' => [
1366 'success' => [ 'type' => 'boolean' ],
1367 'attempted' => [ 'type' => 'integer' ],
1368 'updated' => [ 'type' => 'integer' ],
1369 'failed' => [ 'type' => 'integer' ],
1370 'results' => [
1371 'type' => 'array',
1372 'items' => [
1373 'type' => 'object',
1374 'properties' => [
1375 'id' => [ 'type' => 'integer' ],
1376 'success' => [ 'type' => 'boolean' ],
1377 'message' => [ 'type' => 'string' ],
1378 ],
1379 ],
1380 ],
1381 'message' => [ 'type' => 'string' ],
1382 ],
1383 'required' => [ 'success', 'attempted', 'updated', 'failed', 'results', 'message' ],
1384 ],
1385 'execute_callback' => function( $input = [] ) {
1386 $ids = isset( $input['ids'] ) && is_array( $input['ids'] ) ? array_values( array_unique( array_map( 'intval', $input['ids'] ) ) ) : [];
1387 $field = isset( $input['field'] ) ? sanitize_key( $input['field'] ) : '';
1388 $value = $input['value'] ?? null;
1389
1390 if ( empty( $ids ) ) {
1391 return [
1392 'success' => false,
1393 'attempted' => 0,
1394 'updated' => 0,
1395 'failed' => 0,
1396 'results' => [],
1397 'message' => 'ids is required and must be a non-empty array of positive integers.',
1398 ];
1399 }
1400
1401 $allowed_fields = [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ];
1402 if ( ! in_array( $field, $allowed_fields, true ) ) {
1403 return [
1404 'success' => false,
1405 'attempted' => count( $ids ),
1406 'updated' => 0,
1407 'failed' => 0,
1408 'results' => [],
1409 'message' => sprintf( 'field must be one of: %s.', implode( ', ', $allowed_fields ) ),
1410 ];
1411 }
1412
1413 // Validate the value once up-front. Same rules apply to every post.
1414 $normalized_value = null;
1415 $value_error = null;
1416
1417 switch ( $field ) {
1418 case 'status':
1419 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1420 if ( ! in_array( $s, [ 'publish', 'draft', 'pending', 'private', 'future' ], true ) ) {
1421 $value_error = 'value must be one of: publish, draft, pending, private, future.';
1422 }
1423 $normalized_value = $s;
1424 break;
1425 case 'author':
1426 $a = (int) $value;
1427 if ( $a <= 0 ) {
1428 $value_error = 'value must be a positive user ID.';
1429 } elseif ( ! get_userdata( $a ) ) {
1430 $value_error = sprintf( 'User %d does not exist.', $a );
1431 }
1432 $normalized_value = $a;
1433 break;
1434 case 'parent':
1435 $p = (int) $value;
1436 if ( $p < 0 ) {
1437 $value_error = 'value must be 0 or a positive post ID.';
1438 }
1439 $normalized_value = $p;
1440 break;
1441 case 'comment_status':
1442 case 'ping_status':
1443 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1444 if ( ! in_array( $s, [ 'open', 'closed' ], true ) ) {
1445 $value_error = sprintf( 'value must be "open" or "closed" for %s.', $field );
1446 }
1447 $normalized_value = $s;
1448 break;
1449 }
1450
1451 if ( $value_error !== null ) {
1452 return [
1453 'success' => false,
1454 'attempted' => count( $ids ),
1455 'updated' => 0,
1456 'failed' => 0,
1457 'results' => [],
1458 'message' => $value_error,
1459 ];
1460 }
1461
1462 // Per-id processing.
1463 $results = [];
1464 $updated = 0;
1465 $failed = 0;
1466
1467 foreach ( $ids as $id ) {
1468 if ( $id <= 0 ) {
1469 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Invalid id.' ];
1470 $failed++;
1471 continue;
1472 }
1473
1474 $post = get_post( $id );
1475 if ( ! $post ) {
1476 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Post not found.' ];
1477 $failed++;
1478 continue;
1479 }
1480
1481 $pt_obj = get_post_type_object( $post->post_type );
1482 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1483 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied.' ];
1484 $failed++;
1485 continue;
1486 }
1487
1488 // Field-specific additional checks.
1489 if ( $field === 'status' && in_array( $normalized_value, [ 'publish', 'private', 'future' ], true ) ) {
1490 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1491 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (publish capability required).' ];
1492 $failed++;
1493 continue;
1494 }
1495 }
1496 if ( $field === 'author' && $normalized_value !== (int) $post->post_author ) {
1497 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1498 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (edit_others_posts capability required).' ];
1499 $failed++;
1500 continue;
1501 }
1502 }
1503 if ( $field === 'parent' && $normalized_value > 0 ) {
1504 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1505 $results[] = [ 'id' => $id, 'success' => false, 'message' => sprintf( 'Post type "%s" is not hierarchical.', $post->post_type ) ];
1506 $failed++;
1507 continue;
1508 }
1509 if ( $normalized_value === $id ) {
1510 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1511 $failed++;
1512 continue;
1513 }
1514 }
1515
1516 $update = [ 'ID' => $id ];
1517 switch ( $field ) {
1518 case 'status': $update['post_status'] = $normalized_value; break;
1519 case 'author': $update['post_author'] = $normalized_value; break;
1520 case 'parent': $update['post_parent'] = $normalized_value; break;
1521 case 'comment_status': $update['comment_status'] = $normalized_value; break;
1522 case 'ping_status': $update['ping_status'] = $normalized_value; break;
1523 }
1524
1525 $result = wp_update_post( $update, true );
1526 if ( is_wp_error( $result ) ) {
1527 $results[] = [ 'id' => $id, 'success' => false, 'message' => $result->get_error_message() ];
1528 $failed++;
1529 continue;
1530 }
1531
1532 $results[] = [ 'id' => $id, 'success' => true, 'message' => 'OK.' ];
1533 $updated++;
1534 }
1535
1536 $attempted = count( $ids );
1537
1538 return [
1539 'success' => ( $failed === 0 ),
1540 'attempted' => $attempted,
1541 'updated' => $updated,
1542 'failed' => $failed,
1543 'results' => $results,
1544 'message' => sprintf( '%d of %d updated, %d failed.', $updated, $attempted, $failed ),
1545 ];
1546 },
1547 'permission_callback' => function() {
1548 return current_user_can( 'edit_posts' );
1549 },
1550 'meta' => [
1551 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1552 'annotations' => [
1553 'readonly' => false,
1554 'destructive' => false,
1555 'idempotent' => true,
1556 ],
1557 ],
1558 ] );
1559
1560 // ---- delete-content ----
1561 wp_register_ability( 'atarim/delete-content', [
1562 'label' => 'Delete Content',
1563 'description' => 'Moves a post, page, or custom post type item to trash by default. Pass force: true to permanently delete (skips trash, irreversible).',
1564 'category' => 'atarim',
1565 'input_schema' => [
1566 'type' => 'object',
1567 'properties' => [
1568 'id' => [
1569 'type' => 'integer',
1570 'description' => 'Post ID. Required.',
1571 'minimum' => 1,
1572 ],
1573 'force' => [
1574 'type' => 'boolean',
1575 'description' => 'If true, permanently delete (bypass trash). Defaults to false (move to trash).',
1576 'default' => false,
1577 ],
1578 ],
1579 'required' => [ 'id' ],
1580 'additionalProperties' => false,
1581 ],
1582 'output_schema' => [
1583 'type' => 'object',
1584 'properties' => [
1585 'success' => [ 'type' => 'boolean' ],
1586 'id' => [ 'type' => 'integer' ],
1587 'post_type' => [ 'type' => 'string' ],
1588 'action' => [ 'type' => 'string' ],
1589 'message' => [ 'type' => 'string' ],
1590 ],
1591 'required' => [ 'success', 'message' ],
1592 ],
1593 'execute_callback' => function( $input = [] ) {
1594 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1595 if ( $id <= 0 ) {
1596 return [
1597 'success' => false,
1598 'message' => 'id is required and must be a positive integer.',
1599 ];
1600 }
1601
1602 $force = ! empty( $input['force'] );
1603
1604 $post = get_post( $id );
1605 if ( ! $post ) {
1606 return [
1607 'success' => false,
1608 'message' => sprintf( 'Post %d not found.', $id ),
1609 ];
1610 }
1611
1612 // Per-post-type capability check.
1613 $pt_obj = get_post_type_object( $post->post_type );
1614 if ( $pt_obj && ! current_user_can( $pt_obj->cap->delete_post, $id ) ) {
1615 return [
1616 'success' => false,
1617 'message' => sprintf( 'You do not have permission to delete this %s.', $post->post_type ),
1618 ];
1619 }
1620
1621 $post_type = $post->post_type;
1622
1623 if ( $force ) {
1624 $result = wp_delete_post( $id, true );
1625
1626 if ( ! $result ) {
1627 return [
1628 'success' => false,
1629 'id' => $id,
1630 'post_type' => $post_type,
1631 'action' => 'force_delete',
1632 'message' => 'Permanent delete failed.',
1633 ];
1634 }
1635
1636 return [
1637 'success' => true,
1638 'id' => $id,
1639 'post_type' => $post_type,
1640 'action' => 'force_delete',
1641 'message' => 'Post permanently deleted.',
1642 ];
1643 }
1644
1645 // Trash path — wp_trash_post handles post types that support trash;
1646 // for those that don't (e.g. some CPTs registered without trash support),
1647 // it falls back to wp_delete_post internally.
1648 if ( $post->post_status === 'trash' ) {
1649 return [
1650 'success' => false,
1651 'id' => $id,
1652 'post_type' => $post_type,
1653 'action' => 'trash',
1654 'message' => 'Post is already in trash. Use force: true to permanently delete.',
1655 ];
1656 }
1657
1658 $result = wp_trash_post( $id );
1659
1660 if ( ! $result ) {
1661 return [
1662 'success' => false,
1663 'id' => $id,
1664 'post_type' => $post_type,
1665 'action' => 'trash',
1666 'message' => 'Move to trash failed.',
1667 ];
1668 }
1669
1670 return [
1671 'success' => true,
1672 'id' => $id,
1673 'post_type' => $post_type,
1674 'action' => 'trash',
1675 'message' => 'Post moved to trash.',
1676 ];
1677 },
1678 'permission_callback' => function() {
1679 return current_user_can( 'delete_posts' );
1680 },
1681 'meta' => [
1682 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1683 'annotations' => [
1684 'readonly' => false,
1685 'destructive' => true,
1686 'idempotent' => false,
1687 ],
1688 ],
1689 ] );
1690
1691 // ---- list-revisions ----
1692 wp_register_ability( 'atarim/list-revisions', [
1693 'label' => 'List Revisions',
1694 'description' => 'Returns the revision history for a post, page, or custom post type item. Newest revision first. Each revision includes its ID, the author who saved it, the timestamp, the title and a content_preview (first ~200 chars). Pass include_content: true to also return full revision bodies — useful when the AI needs to diff revisions, but response size grows. Revisions are WordPress\'s automatic save history; not all post types track revisions (post and page do by default).',
1695 'category' => 'atarim',
1696 'input_schema' => [
1697 'type' => 'object',
1698 'properties' => [
1699 'id' => [
1700 'type' => 'integer',
1701 'description' => 'Parent post ID.',
1702 'minimum' => 1,
1703 ],
1704 'limit' => [
1705 'type' => 'integer',
1706 'description' => 'Max revisions to return. -1 for all. Defaults to 20.',
1707 'default' => 20,
1708 'minimum' => -1,
1709 ],
1710 'include_content' => [
1711 'type' => 'boolean',
1712 'description' => 'Include the full content of each revision under "content". Defaults to false; only a short content_preview is returned per revision.',
1713 'default' => false,
1714 ],
1715 ],
1716 'required' => [ 'id' ],
1717 'additionalProperties' => false,
1718 ],
1719 'output_schema' => [
1720 'type' => 'object',
1721 'properties' => [
1722 'success' => [ 'type' => 'boolean' ],
1723 'post_id' => [ 'type' => 'integer' ],
1724 'total' => [ 'type' => 'integer' ],
1725 'returned' => [ 'type' => 'integer' ],
1726 'revisions' => [
1727 'type' => 'array',
1728 'items' => [
1729 'type' => 'object',
1730 'properties' => [
1731 'revision_id' => [ 'type' => 'integer' ],
1732 'date' => [ 'type' => 'string' ],
1733 'author' => [ 'type' => 'integer' ],
1734 'author_name' => [ 'type' => 'string' ],
1735 'title' => [ 'type' => 'string' ],
1736 'excerpt' => [ 'type' => 'string' ],
1737 'content_preview' => [ 'type' => 'string' ],
1738 'content' => [ 'type' => 'string' ],
1739 ],
1740 ],
1741 ],
1742 'message' => [ 'type' => 'string' ],
1743 ],
1744 'required' => [ 'success', 'post_id', 'total', 'returned', 'revisions' ],
1745 ],
1746 'execute_callback' => function( $input = [] ) {
1747 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1748 if ( $id <= 0 ) {
1749 return [
1750 'success' => false,
1751 'post_id' => 0,
1752 'total' => 0,
1753 'returned' => 0,
1754 'revisions' => [],
1755 'message' => 'id is required and must be a positive integer.',
1756 ];
1757 }
1758
1759 $post = get_post( $id );
1760 if ( ! $post ) {
1761 return [
1762 'success' => false,
1763 'post_id' => $id,
1764 'total' => 0,
1765 'returned' => 0,
1766 'revisions' => [],
1767 'message' => sprintf( 'Post %d not found.', $id ),
1768 ];
1769 }
1770
1771 $pt_obj = get_post_type_object( $post->post_type );
1772 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1773 return [
1774 'success' => false,
1775 'post_id' => $id,
1776 'total' => 0,
1777 'returned' => 0,
1778 'revisions' => [],
1779 'message' => sprintf( 'You do not have permission to read revisions for this %s.', $post->post_type ),
1780 ];
1781 }
1782
1783 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
1784 $include_content = ! empty( $input['include_content'] );
1785
1786 // wp_get_post_revisions returns newest-first by default. Auto-draft revisions
1787 // are included; we filter those out as they're noise for an AI caller.
1788 $args = [];
1789 if ( $limit > 0 ) {
1790 $args['posts_per_page'] = $limit;
1791 }
1792 $all = wp_get_post_revisions( $id, $args );
1793
1794 $revisions = [];
1795 foreach ( $all as $rev ) {
1796 // Skip autosaves — not part of the human-visible revision history.
1797 if ( wp_is_post_autosave( $rev ) ) {
1798 continue;
1799 }
1800
1801 $author_obj = get_userdata( (int) $rev->post_author );
1802 $author_name = $author_obj ? $author_obj->display_name : '';
1803
1804 $stripped = trim( wp_strip_all_tags( strip_shortcodes( $rev->post_content ) ) );
1805 $stripped = preg_replace( '/\s+/', ' ', $stripped );
1806 $content_preview = ( strlen( $stripped ) > 200 )
1807 ? substr( $stripped, 0, 200 ) . ''
1808 : $stripped;
1809
1810 $entry = [
1811 'revision_id' => (int) $rev->ID,
1812 'date' => $rev->post_date_gmt,
1813 'author' => (int) $rev->post_author,
1814 'author_name' => $author_name,
1815 'title' => $rev->post_title,
1816 'excerpt' => $rev->post_excerpt,
1817 'content_preview' => $content_preview,
1818 ];
1819 if ( $include_content ) {
1820 $entry['content'] = $rev->post_content;
1821 }
1822 $revisions[] = $entry;
1823 }
1824
1825 return [
1826 'success' => true,
1827 'post_id' => $id,
1828 'total' => count( $revisions ),
1829 'returned' => count( $revisions ),
1830 'revisions' => $revisions,
1831 'message' => sprintf( '%d revision(s) found for post %d.', count( $revisions ), $id ),
1832 ];
1833 },
1834 'permission_callback' => function() {
1835 return current_user_can( 'edit_posts' );
1836 },
1837 'meta' => [
1838 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1839 'annotations' => [
1840 'readonly' => true,
1841 'destructive' => false,
1842 'idempotent' => true,
1843 ],
1844 ],
1845 ] );
1846
1847 // ---- restore-revision ----
1848 wp_register_ability( 'atarim/restore-revision', [
1849 'label' => 'Restore Revision',
1850 'description' => 'Restores a post to the state captured in a prior revision. The revision_id is taken from list-revisions output. The current post content is replaced by the revision\'s content; WordPress typically captures the pre-restore state as a new revision in the chain (so the operation is not destructive in the catastrophic sense), but the AI should not rely on that for rollback safety and should call list-revisions before AND after to confirm.',
1851 'category' => 'atarim',
1852 'input_schema' => [
1853 'type' => 'object',
1854 'properties' => [
1855 'revision_id' => [
1856 'type' => 'integer',
1857 'description' => 'Revision ID to restore. Obtained from list-revisions.',
1858 'minimum' => 1,
1859 ],
1860 ],
1861 'required' => [ 'revision_id' ],
1862 'additionalProperties' => false,
1863 ],
1864 'output_schema' => [
1865 'type' => 'object',
1866 'properties' => [
1867 'success' => [ 'type' => 'boolean' ],
1868 'revision_id' => [ 'type' => 'integer' ],
1869 'post_id' => [ 'type' => 'integer' ],
1870 'title' => [ 'type' => 'string' ],
1871 'restored_from_date' => [ 'type' => 'string' ],
1872 'message' => [ 'type' => 'string' ],
1873 ],
1874 'required' => [ 'success', 'message' ],
1875 ],
1876 'execute_callback' => function( $input = [] ) {
1877 $revision_id = isset( $input['revision_id'] ) ? (int) $input['revision_id'] : 0;
1878 if ( $revision_id <= 0 ) {
1879 return [
1880 'success' => false,
1881 'revision_id' => 0,
1882 'post_id' => 0,
1883 'message' => 'revision_id is required and must be a positive integer.',
1884 ];
1885 }
1886
1887 $revision = wp_get_post_revision( $revision_id );
1888 if ( ! $revision ) {
1889 return [
1890 'success' => false,
1891 'revision_id' => $revision_id,
1892 'post_id' => 0,
1893 'message' => sprintf( 'Revision %d not found.', $revision_id ),
1894 ];
1895 }
1896
1897 $parent_id = (int) $revision->post_parent;
1898 $parent = get_post( $parent_id );
1899 if ( ! $parent ) {
1900 return [
1901 'success' => false,
1902 'revision_id' => $revision_id,
1903 'post_id' => $parent_id,
1904 'message' => sprintf( 'Parent post %d for revision %d no longer exists.', $parent_id, $revision_id ),
1905 ];
1906 }
1907
1908 $pt_obj = get_post_type_object( $parent->post_type );
1909 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $parent_id ) ) {
1910 return [
1911 'success' => false,
1912 'revision_id' => $revision_id,
1913 'post_id' => $parent_id,
1914 'message' => sprintf( 'You do not have permission to restore revisions for this %s.', $parent->post_type ),
1915 ];
1916 }
1917
1918 $result = wp_restore_post_revision( $revision_id );
1919
1920 if ( is_wp_error( $result ) ) {
1921 return [
1922 'success' => false,
1923 'revision_id' => $revision_id,
1924 'post_id' => $parent_id,
1925 'message' => 'Restore failed: ' . $result->get_error_message(),
1926 ];
1927 }
1928
1929 if ( $result === null || $result === false ) {
1930 return [
1931 'success' => false,
1932 'revision_id' => $revision_id,
1933 'post_id' => $parent_id,
1934 'message' => 'Restore failed: WordPress reported the operation did not complete.',
1935 ];
1936 }
1937
1938 $restored_post = get_post( $parent_id );
1939
1940 return [
1941 'success' => true,
1942 'revision_id' => $revision_id,
1943 'post_id' => $parent_id,
1944 'title' => $restored_post ? $restored_post->post_title : '',
1945 'restored_from_date' => $revision->post_date_gmt,
1946 'message' => sprintf( 'Post %d restored from revision %d (dated %s).', $parent_id, $revision_id, $revision->post_date_gmt ),
1947 ];
1948 },
1949 'permission_callback' => function() {
1950 return current_user_can( 'edit_posts' );
1951 },
1952 'meta' => [
1953 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1954 'annotations' => [
1955 'readonly' => false,
1956 'destructive' => false,
1957 'idempotent' => true,
1958 ],
1959 ],
1960 ] );
1961
1962 // ---- duplicate-post ----
1963 wp_register_ability( 'atarim/duplicate-post', [
1964 'label' => 'Duplicate Post',
1965 'description' => 'Duplicates a post, page, or custom post type item. Copies core fields, all post meta (including page-builder payloads such as Elementor/Bricks data) except editing-lock and old-slug keys, and all taxonomy terms (categories, tags, custom taxonomies). The featured image is shared (same attachment). The new item is set to draft status with " (Copy)" appended to the title and a unique slug, and the acting user becomes the author. Comments are not copied. Single item only: child posts and attachments are not duplicated. Only public, non-internal post types are allowed. Returns the new post id, slug, status, title, and edit/preview links.',
1966 'category' => 'atarim',
1967 'input_schema' => [
1968 'type' => 'object',
1969 'properties' => [
1970 'post_id' => [
1971 'type' => 'integer',
1972 'description' => 'ID of the post, page, or custom post type item to duplicate.',
1973 ],
1974 ],
1975 'required' => [ 'post_id' ],
1976 'additionalProperties' => false,
1977 ],
1978 'output_schema' => [
1979 'type' => 'object',
1980 'properties' => [
1981 'success' => [ 'type' => 'boolean' ],
1982 'new_post_id' => [ 'type' => 'integer' ],
1983 'source_post_id' => [ 'type' => 'integer' ],
1984 'new_slug' => [ 'type' => 'string' ],
1985 'status' => [ 'type' => 'string' ],
1986 'title' => [ 'type' => 'string' ],
1987 'edit_link' => [ 'type' => 'string' ],
1988 'preview_link' => [ 'type' => 'string' ],
1989 'message' => [ 'type' => 'string' ],
1990 ],
1991 'required' => [ 'success', 'message' ],
1992 ],
1993 'execute_callback' => function( $input ) {
1994 $source_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
1995 if ( ! $source_id ) {
1996 return [ 'success' => false, 'message' => 'A valid post_id is required.' ];
1997 }
1998
1999 $source = get_post( $source_id );
2000 if ( ! $source ) {
2001 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $source_id ) ];
2002 }
2003
2004 $post_type = $source->post_type;
2005 $pt_obj = get_post_type_object( $post_type );
2006 if ( ! $pt_obj ) {
2007 return [ 'success' => false, 'message' => sprintf( 'Unknown post type "%s".', $post_type ) ];
2008 }
2009
2010 // Block internal / non-public post types.
2011 $excluded = [
2012 'attachment', 'revision', 'nav_menu_item', 'custom_css',
2013 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block',
2014 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation',
2015 ];
2016 if ( in_array( $post_type, $excluded, true ) || empty( $pt_obj->public ) ) {
2017 return [ 'success' => false, 'message' => sprintf( 'Post type "%s" cannot be duplicated (internal or non-public).', $post_type ) ];
2018 }
2019
2020 // Capability: must be able to edit the source and create the target type.
2021 if ( ! current_user_can( $pt_obj->cap->edit_post, $source_id ) ) {
2022 return [ 'success' => false, 'message' => 'You do not have permission to duplicate this post.' ];
2023 }
2024 if ( ! current_user_can( $pt_obj->cap->create_posts ) ) {
2025 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to create %s items.', $post_type ) ];
2026 }
2027
2028 // Build the new title + a unique draft slug (wp_insert_post does not
2029 // uniquify slugs for drafts, so compute it explicitly).
2030 $new_title = ( '' !== $source->post_title ) ? $source->post_title . ' (Copy)' : '(Copy)';
2031 $desired_slug = sanitize_title( $new_title );
2032 $unique_slug = wp_unique_post_slug( $desired_slug, 0, 'draft', $post_type, (int) $source->post_parent );
2033
2034 $acting_user = get_current_user_id();
2035
2036 $postarr = [
2037 'post_title' => $new_title,
2038 'post_name' => $unique_slug,
2039 'post_content' => $source->post_content,
2040 'post_excerpt' => $source->post_excerpt,
2041 'post_status' => 'draft',
2042 'post_type' => $post_type,
2043 'post_author' => $acting_user ? $acting_user : (int) $source->post_author,
2044 'post_parent' => (int) $source->post_parent,
2045 'menu_order' => (int) $source->menu_order,
2046 'comment_status' => $source->comment_status,
2047 'ping_status' => $source->ping_status,
2048 'post_password' => $source->post_password,
2049 ];
2050
2051 $new_id = wp_insert_post( wp_slash( $postarr ), true );
2052 if ( is_wp_error( $new_id ) ) {
2053 return [ 'success' => false, 'message' => 'Duplicate failed: ' . $new_id->get_error_message() ];
2054 }
2055
2056 // Copy taxonomy terms for every taxonomy on this post type
2057 // (includes categories, tags, custom taxonomies and post_format).
2058 foreach ( get_object_taxonomies( $post_type ) as $taxonomy ) {
2059 $term_ids = wp_get_object_terms( $source_id, $taxonomy, [ 'fields' => 'ids' ] );
2060 if ( ! is_wp_error( $term_ids ) && ! empty( $term_ids ) ) {
2061 wp_set_object_terms( $new_id, $term_ids, $taxonomy, false );
2062 }
2063 }
2064
2065 // Copy post meta (multi-value safe). _thumbnail_id is copied here, so the
2066 // featured image is shared. Editing-lock and old-slug keys are skipped.
2067 $skip_meta = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date' ];
2068 $all_meta = get_post_meta( $source_id );
2069 if ( is_array( $all_meta ) ) {
2070 foreach ( $all_meta as $meta_key => $meta_values ) {
2071 if ( in_array( $meta_key, $skip_meta, true ) ) {
2072 continue;
2073 }
2074 foreach ( (array) $meta_values as $meta_value ) {
2075 add_post_meta( $new_id, $meta_key, wp_slash( maybe_unserialize( $meta_value ) ) );
2076 }
2077 }
2078 }
2079
2080 $new_post = get_post( $new_id );
2081 $preview_link = get_preview_post_link( $new_id );
2082
2083 return [
2084 'success' => true,
2085 'new_post_id' => (int) $new_id,
2086 'source_post_id' => $source_id,
2087 'new_slug' => $new_post ? $new_post->post_name : $unique_slug,
2088 'status' => 'draft',
2089 'title' => $new_title,
2090 'edit_link' => admin_url( 'post.php?post=' . (int) $new_id . '&action=edit' ),
2091 'preview_link' => $preview_link ? $preview_link : '',
2092 'message' => sprintf( 'Duplicated post %d as draft %d ("%s").', $source_id, (int) $new_id, $new_title ),
2093 ];
2094 },
2095 'permission_callback' => function() {
2096 return current_user_can( 'edit_posts' );
2097 },
2098 'meta' => [
2099 'mcp' => [ 'public' => true, 'type' => 'tool' ],
2100 'annotations' => [
2101 'readonly' => false,
2102 'destructive' => false,
2103 'idempotent' => false,
2104 ],
2105 ],
2106 ] );
2107 }
2108 }