group(
'wordpress',
array(
'label' => 'WordPress',
'description' => 'Site info, posts, pages, custom post types, search, media, menus and taxonomies.',
'detected' => true,
'plugins' => array( 'WordPress' => get_bloginfo( 'version' ) ),
)
);
$r->add(
array(
'name' => 'wp_site_info',
'group' => 'wordpress',
'title' => 'Site info',
'description' => 'Start here. Site name and URL, WordPress version, active theme, active plugins, post types, which integrations are available (builders, WooCommerce, forms, security) and what your account may do. Plugin versions and the PHP version are shown to administrators only.',
'cap' => 'read', // Always available, so an app can learn why it has no other tools.
'run' => array( __CLASS__, 'site_info' ),
)
);
$r->add(
array(
'name' => 'site_style_guide',
'group' => 'wordpress',
'title' => 'Site style guide',
'description' => 'The detailed style guide the site owner wrote: longer rules for content, page layouts, code and design. Read it before you write content, build or redesign pages, or change code when the site brief says a guide exists.',
'cap' => 'read',
'run' => array( __CLASS__, 'style_guide' ),
)
);
$r->add(
array(
'name' => 'wp_list_posts',
'group' => 'wordpress',
'title' => 'List posts or pages',
'description' => 'List posts, pages or any custom post type. Each item shows which builder owns its layout (elementor, gutenberg, wpbakery, avada, classic...).',
'cap' => 'edit_posts',
'input' => array(
'properties' => array(
'post_type' => array( 'type' => 'string', 'default' => 'page', 'description' => 'post, page, product, or any registered post type.' ),
'status' => array( 'type' => 'string', 'enum' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash', 'any' ), 'default' => 'any', 'description' => 'publish, draft, pending, private, future, trash or any.' ),
'search' => array( 'type' => 'string', 'maxLength' => 200 ),
'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20 ),
'page' => array( 'type' => 'integer', 'minimum' => 1, 'default' => 1 ),
),
),
'run' => static function ( array $a ) {
if ( ! post_type_exists( $a['post_type'] ) ) {
return new \WP_Error( 'invalid_post_type', 'Unknown post type: ' . $a['post_type'] );
}
$args = array(
'post_type' => $a['post_type'],
'post_status' => $a['status'],
's' => $a['search'] ?? '',
'posts_per_page' => $a['per_page'],
'paged' => $a['page'],
'orderby' => 'modified',
'order' => 'DESC',
);
if ( 'publish' !== $a['status'] ) {
$args['perm'] = 'editable';
}
$q = new \WP_Query( $args );
$items = array();
foreach ( $q->posts as $p ) {
if ( current_user_can( 'edit_post', $p->ID ) ) {
$items[] = Util::summary( $p );
}
}
return array( 'items' => $items, 'total' => (int) $q->found_posts, 'pages' => (int) $q->max_num_pages );
},
)
);
$r->add(
array(
'name' => 'wp_get_post',
'group' => 'wordpress',
'title' => 'Get a post or page',
'description' => 'Full post: title, raw content, excerpt, status, template, parent, featured image, taxonomy terms, public custom fields and the builder that owns the layout. For builder layouts use that builder\'s get tool.',
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'id' ),
'properties' => array( 'id' => Util::id() ),
),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'] );
if ( is_wp_error( $p ) ) {
return $p;
}
$meta = array();
foreach ( get_post_meta( $p->ID ) as $key => $values ) {
if ( is_protected_meta( $key, 'post' ) ) {
continue;
}
$meta[ $key ] = $values[0]; // get_post_meta() already unserialized it.
}
$terms = array();
foreach ( get_object_taxonomies( $p->post_type ) as $tax ) {
$names = wp_get_post_terms( $p->ID, $tax, array( 'fields' => 'names' ) );
if ( ! is_wp_error( $names ) && $names ) {
$terms[ $tax ] = $names;
}
}
return array_merge(
Util::summary( $p ),
array(
'content' => $p->post_content,
'excerpt' => $p->post_excerpt,
'parent' => $p->post_parent,
'menu_order' => $p->menu_order,
'template' => get_page_template_slug( $p ) ? get_page_template_slug( $p ) : 'default',
'featured_image' => (int) get_post_thumbnail_id( $p ),
'author' => (int) $p->post_author,
'date' => $p->post_date,
'terms' => $terms,
'meta' => $meta,
)
);
},
)
);
$post_props = array(
'title' => array( 'type' => 'string', 'maxLength' => 500 ),
'content' => array( 'type' => 'string', 'description' => 'HTML or block markup. For builder pages use the builder tools instead.' ),
'excerpt' => array( 'type' => 'string' ),
'status' => Util::status_prop(),
'slug' => array( 'type' => 'string', 'maxLength' => 200 ),
'parent' => array( 'type' => 'integer', 'minimum' => 0 ),
'menu_order' => array( 'type' => 'integer' ),
'template' => array( 'type' => 'string', 'description' => 'Page template file, e.g. elementor_canvas, or "default".' ),
'date' => array( 'type' => 'string', 'description' => 'Publish date Y-m-d H:i:s (site time). Needed for status future.' ),
'featured_image' => array( 'type' => 'integer', 'minimum' => 0, 'description' => 'Attachment ID, 0 removes it.' ),
'terms' => array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'array', 'items' => array( 'type' => array( 'string', 'integer' ) ) ), 'description' => 'taxonomy => list of term names or IDs, e.g. {"category":["News"]}. Replaces existing terms of that taxonomy.' ),
'meta' => array( 'type' => 'object', 'additionalProperties' => true, 'description' => 'Public custom fields (keys starting with _ are refused).' ),
);
$r->add(
array(
'name' => 'wp_create_post',
'group' => 'wordpress',
'title' => 'Create a post or page',
'description' => 'Create a post, page or custom post type item. New items are drafts unless you pass a status. To build a page with a page builder, use that builder\'s save tool instead (it creates the page too).',
'write' => true,
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'title' ),
'properties' => array_merge( array( 'post_type' => array( 'type' => 'string', 'default' => 'post' ) ), $post_props ),
),
'run' => static function ( array $a ) {
$ok = Util::generic_type( (string) $a['post_type'] );
if ( ! is_wp_error( $ok ) ) {
$ok = Util::can_write_type( $a['post_type'], 0, (string) ( $a['status'] ?? 'draft' ) );
}
if ( is_wp_error( $ok ) ) {
return $ok;
}
return self::save_post( 0, $a );
},
)
);
$r->add(
array(
'name' => 'wp_update_post',
'live_guard' => 'content',
'group' => 'wordpress',
'title' => 'Update a post or page',
'description' => 'Update fields of any post type. Only the fields you pass change. Do not use content for builder pages (Elementor, WPBakery, Avada...), use the builder tools.',
'write' => true,
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'id' ),
'properties' => array_merge( array( 'id' => Util::id() ), $post_props ),
),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'] );
if ( is_wp_error( $p ) ) {
return $p;
}
$ok = Util::generic_type( $p->post_type );
if ( ! is_wp_error( $ok ) ) {
$ok = Util::can_write_type( $p->post_type, $p->ID, (string) ( $a['status'] ?? '' ) );
}
if ( is_wp_error( $ok ) ) {
return $ok;
}
if ( isset( $a['content'] ) && in_array( Util::builder_of( $p->ID ), array( 'elementor', 'bricks', 'beaver', 'divi' ), true ) ) {
return new \WP_Error( 'builder_page', 'This page is built with ' . Util::builder_of( $p->ID ) . '. Its layout is not stored in post content. Use the builder tools to change it.' );
}
return self::save_post( $p->ID, $a );
},
)
);
$r->add(
array(
'name' => 'wp_trash_post',
'group' => 'wordpress',
'title' => 'Move to trash',
'description' => 'Move a post, page or item to the trash. It can be restored with wp_restore_post.',
'write' => true,
'cap' => 'edit_posts',
'input' => array( 'required' => array( 'id' ), 'properties' => array( 'id' => Util::id() ) ),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'], null, 'delete' );
if ( is_wp_error( $p ) ) {
return $p;
}
$res = wp_trash_post( $p->ID );
if ( ! $res ) {
return new \WP_Error( 'trash_failed', 'WordPress could not trash this item.' );
}
return array( 'id' => $p->ID, 'status' => get_post_status( $p->ID ), 'message' => 'Moved to trash.' );
},
)
);
$r->add(
array(
'name' => 'wp_restore_post',
'group' => 'wordpress',
'title' => 'Restore from trash',
'description' => 'Restore a trashed post or page (it comes back as a draft).',
'write' => true,
'cap' => 'edit_posts',
'input' => array( 'required' => array( 'id' ), 'properties' => array( 'id' => Util::id() ) ),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'], null, 'delete' );
if ( is_wp_error( $p ) ) {
return $p;
}
if ( 'trash' !== $p->post_status ) {
return new \WP_Error( 'not_trashed', 'This item is not in the trash.' );
}
wp_untrash_post( $p->ID );
return array( 'id' => $p->ID, 'status' => get_post_status( $p->ID ), 'message' => 'Restored.' );
},
)
);
$r->add(
array(
'name' => 'wp_delete_post_permanently',
'group' => 'wordpress',
'title' => 'Delete permanently',
'description' => 'Permanently delete a post, page or item, including its revisions. Cannot be undone.',
'write' => true,
'confirm' => true,
'cap' => 'delete_posts',
'input' => array( 'required' => array( 'id' ), 'properties' => array( 'id' => Util::id() ) ),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'], null, 'delete' );
if ( is_wp_error( $p ) ) {
return $p;
}
if ( ! wp_delete_post( $p->ID, true ) ) {
return new \WP_Error( 'delete_failed', 'WordPress could not delete this item.' );
}
return array( 'id' => $a['id'], 'deleted' => ! get_post( $a['id'] ), 'message' => 'Deleted permanently.' );
},
)
);
$r->add(
array(
'name' => 'wp_search',
'group' => 'wordpress',
'title' => 'Search the site',
'description' => 'Find text in titles, content and page-builder layouts (Elementor data included). Each hit names the post, where the text lives and the builder that owns it, so you know which tool edits it.',
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'query' ),
'properties' => array(
'query' => array( 'type' => 'string', 'minLength' => 2, 'maxLength' => 200 ),
'post_types' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30 ),
),
),
'run' => array( __CLASS__, 'search' ),
)
);
$r->add(
array(
'name' => 'wp_edit_text',
'group' => 'wordpress',
'title' => 'Replace exact text',
'description' => 'Replace an exact piece of text in one post field (post_content, post_excerpt, post_title) or one custom field, without sending the whole value. The old text must match exactly once unless replace_all is true. Works for small text fixes inside builder data too (e.g. meta_key _elementor_data): Elementor caches are cleared afterwards. Block markup and JSON are checked so an edit cannot break them.',
'write' => true,
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'id', 'old_text', 'new_text' ),
'properties' => array(
'id' => Util::id(),
'field' => array( 'type' => 'string', 'enum' => array( 'post_content', 'post_excerpt', 'post_title' ), 'description' => 'Post field. Leave empty when using meta_key.' ),
'meta_key' => array( 'type' => 'string', 'maxLength' => 190 ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- input schema property name, not a query argument.
'old_text' => array( 'type' => 'string', 'minLength' => 1 ),
'new_text' => array( 'type' => 'string' ),
'replace_all' => array( 'type' => 'boolean', 'default' => false ),
),
),
'run' => array( __CLASS__, 'edit_text' ),
)
);
$r->add(
array(
'name' => 'wp_list_terms',
'group' => 'wordpress',
'title' => 'List categories, tags or terms',
'cap' => 'edit_posts',
'input' => array(
'properties' => array(
'taxonomy' => array( 'type' => 'string', 'default' => 'category' ),
'search' => array( 'type' => 'string' ),
'limit' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 500, 'default' => 100 ),
),
),
'run' => static function ( array $a ) {
$tax = get_taxonomy( $a['taxonomy'] );
if ( ! $tax ) {
return new \WP_Error( 'invalid_taxonomy', 'Unknown taxonomy: ' . $a['taxonomy'] );
}
if ( ! $tax->show_ui || ! current_user_can( $tax->cap->assign_terms ) ) {
return new \WP_Error( 'forbidden', 'You cannot use terms of taxonomy ' . $a['taxonomy'] . '.' );
}
$terms = get_terms( array( 'taxonomy' => $a['taxonomy'], 'hide_empty' => false, 'search' => $a['search'] ?? '', 'number' => $a['limit'] ) );
if ( is_wp_error( $terms ) ) {
return $terms;
}
return array(
'terms' => array_map(
static function ( $t ) {
return array( 'id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'parent' => $t->parent, 'count' => $t->count, 'description' => $t->description );
},
$terms
),
);
},
)
);
$r->add(
array(
'name' => 'wp_create_term',
'group' => 'wordpress',
'title' => 'Create a category, tag or term',
'write' => true,
'cap' => 'manage_categories',
'input' => array(
'required' => array( 'name' ),
'properties' => array(
'taxonomy' => array( 'type' => 'string', 'default' => 'category' ),
'name' => array( 'type' => 'string', 'maxLength' => 200 ),
'slug' => array( 'type' => 'string' ),
'description' => array( 'type' => 'string' ),
'parent' => array( 'type' => 'integer', 'minimum' => 0 ),
),
),
'run' => static function ( array $a ) {
$tax = get_taxonomy( $a['taxonomy'] );
if ( ! $tax || ! current_user_can( $tax->cap->manage_terms ) ) {
return new \WP_Error( 'forbidden', 'Unknown taxonomy or not allowed.' );
}
$res = wp_insert_term( sanitize_text_field( $a['name'] ), $a['taxonomy'], array( 'slug' => $a['slug'] ?? '', 'description' => wp_kses_post( $a['description'] ?? '' ), 'parent' => $a['parent'] ?? 0 ) );
if ( is_wp_error( $res ) ) {
return $res;
}
return array( 'id' => $res['term_id'], 'taxonomy' => $a['taxonomy'], 'message' => 'Term created.' );
},
)
);
$r->add(
array(
'name' => 'wp_update_term',
'group' => 'wordpress',
'title' => 'Edit a category, tag or term',
'description' => 'Change the name, slug, description or parent of a category, tag or custom taxonomy term. Only the fields you pass change. Can be undone.',
'write' => true,
'cap' => 'manage_categories',
'input' => array(
'required' => array( 'id' ),
'properties' => array(
'id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => 'Term ID (see wp_list_terms).' ),
'taxonomy' => array( 'type' => 'string', 'default' => 'category' ),
'name' => array( 'type' => 'string', 'maxLength' => 200 ),
'slug' => array( 'type' => 'string', 'maxLength' => 200 ),
'description' => array( 'type' => 'string' ),
'parent' => array( 'type' => 'integer', 'minimum' => 0 ),
),
),
'run' => static function ( array $a ) {
$term = get_term( (int) $a['id'], $a['taxonomy'] );
if ( ! $term || is_wp_error( $term ) ) {
return new \WP_Error( 'not_found', 'No term with that ID in taxonomy ' . $a['taxonomy'] . '.' );
}
if ( ! current_user_can( 'edit_term', $term->term_id ) ) {
return new \WP_Error( 'forbidden', 'You are not allowed to edit this term.' );
}
$args = array();
if ( isset( $a['name'] ) ) {
$args['name'] = sanitize_text_field( $a['name'] );
}
if ( isset( $a['slug'] ) ) {
$args['slug'] = sanitize_title( $a['slug'] );
}
if ( isset( $a['description'] ) ) {
$args['description'] = wp_kses_post( $a['description'] );
}
if ( isset( $a['parent'] ) ) {
if ( (int) $a['parent'] === (int) $term->term_id ) {
return new \WP_Error( 'invalid_parent', 'A term cannot be its own parent.' );
}
$args['parent'] = (int) $a['parent'];
}
if ( ! $args ) {
return new \WP_Error( 'nothing_to_change', 'Pass at least one of name, slug, description or parent.' );
}
$res = wp_update_term( $term->term_id, $a['taxonomy'], $args );
if ( is_wp_error( $res ) ) {
return $res;
}
$t = get_term( $term->term_id, $a['taxonomy'] );
return array( 'id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'parent' => $t->parent, 'description' => $t->description, 'url' => get_term_link( $t ), 'message' => 'Term updated.' );
},
)
);
$r->add(
array(
'name' => 'wp_delete_term',
'group' => 'wordpress',
'title' => 'Delete a category, tag or term',
'description' => 'Delete a category, tag or custom taxonomy term. Posts are kept; posts left without a category move to the default category. The default category cannot be deleted. Ask the user first.',
'write' => true,
'confirm' => true,
'cap' => 'manage_categories',
'input' => array(
'required' => array( 'id' ),
'properties' => array(
'id' => array( 'type' => 'integer', 'minimum' => 1 ),
'taxonomy' => array( 'type' => 'string', 'default' => 'category' ),
),
),
'run' => static function ( array $a ) {
$term = get_term( (int) $a['id'], $a['taxonomy'] );
if ( ! $term || is_wp_error( $term ) ) {
return new \WP_Error( 'not_found', 'No term with that ID in taxonomy ' . $a['taxonomy'] . '.' );
}
if ( ! current_user_can( 'delete_term', $term->term_id ) ) {
return new \WP_Error( 'forbidden', 'You are not allowed to delete this term.' );
}
if ( 'category' === $term->taxonomy && (int) get_option( 'default_category' ) === (int) $term->term_id ) {
return new \WP_Error( 'default_category', 'The default category cannot be deleted. Make another category the default first.' );
}
\HandyMCP\Tools\History::term( (int) $term->term_id );
$res = wp_delete_term( $term->term_id, $term->taxonomy );
if ( is_wp_error( $res ) || ! $res ) {
return is_wp_error( $res ) ? $res : new \WP_Error( 'delete_failed', 'The term could not be deleted.' );
}
return array( 'id' => (int) $term->term_id, 'name' => $term->name, 'posts_affected' => (int) $term->count, 'message' => 'Term deleted.' );
},
)
);
$r->add(
array(
'name' => 'wp_duplicate_post',
'group' => 'wordpress',
'title' => 'Duplicate a page, post or any content',
'description' => 'Make an exact copy of any post, page, product, template or custom post type item, with all page-builder data (Elementor, WPBakery, Avada, Divi, blocks), custom fields, SEO fields, categories and featured image. The copy is a draft by default, so it is the safe way to redesign a live page: duplicate, edit the copy, then let the user publish it. WooCommerce products are copied with their variations.',
'write' => true,
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'id' ),
'properties' => array(
'id' => Util::id(),
'title' => array( 'type' => 'string', 'maxLength' => 300, 'description' => 'Title of the copy. Default: "
(copy)".' ),
'status' => array( 'type' => 'string', 'enum' => array( 'draft', 'pending', 'private', 'publish' ), 'default' => 'draft' ),
'slug' => array( 'type' => 'string', 'maxLength' => 200 ),
),
),
'run' => array( __CLASS__, 'duplicate' ),
)
);
$r->add(
array(
'name' => 'wp_preview_link',
'group' => 'wordpress',
'title' => 'Shareable preview link for a draft',
'description' => 'Create a link that shows a draft, pending or scheduled page or post to anyone (for example a client) without logging in. The link stops working after the given hours or when the post is published.',
'write' => true,
'cap' => 'edit_posts',
'input' => array(
'required' => array( 'id' ),
'properties' => array(
'id' => Util::id(),
'hours' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 720, 'default' => 72 ),
),
),
'run' => array( __CLASS__, 'preview_link' ),
)
);
self::register_media( $r );
self::register_menus( $r );
}
/**
* Create an expiring public preview link for an unpublished post.
*
* @param array $a Args.
* @return array|\WP_Error
*/
public static function preview_link( array $a ) {
$post = get_post( (int) $a['id'] );
if ( ! $post ) {
return new \WP_Error( 'not_found', 'Post not found.' );
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new \WP_Error( 'forbidden', 'You cannot edit this post.' );
}
if ( 'publish' === $post->post_status ) {
return array( 'url' => get_permalink( $post ), 'message' => 'Already published; this is the normal link.' );
}
$token = wp_generate_password( 24, false );
$expires = time() + HOUR_IN_SECONDS * (int) ( $a['hours'] ?? 72 );
update_post_meta( $post->ID, '_handymcp_preview', array( 'hash' => wp_hash( $token ), 'expires' => $expires ) );
$key = 'page' === $post->post_type ? 'page_id' : 'p';
$url = add_query_arg( array_filter( array( $key => $post->ID, 'post_type' => 'page' === $post->post_type || 'post' === $post->post_type ? '' : $post->post_type, 'handymcp_preview' => $token ) ), home_url( '/' ) );
return array( 'url' => $url, 'expires' => gmdate( 'c', $expires ), 'message' => 'Anyone with this link can view the draft until it expires. Create a new link to cancel the old one.' );
}
/**
* Let a valid preview link show an unpublished post (front end).
*/
public static function preview_hooks(): void {
if ( empty( $_GET['handymcp_preview'] ) || is_admin() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
add_filter(
'posts_results',
static function ( $posts, $query ) {
if ( ! $query->is_main_query() || 1 !== count( (array) $posts ) ) {
return $posts;
}
$token = isset( $_GET['handymcp_preview'] ) ? sanitize_text_field( wp_unslash( $_GET['handymcp_preview'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$meta = get_post_meta( $posts[0]->ID, '_handymcp_preview', true );
if ( '' !== $token && is_array( $meta ) && isset( $meta['expires'], $meta['hash'] ) && (int) $meta['expires'] > time() && hash_equals( (string) $meta['hash'], wp_hash( $token ) ) && in_array( $posts[0]->post_status, array( 'draft', 'pending', 'future', 'private' ), true ) ) {
add_filter( 'redirect_canonical', '__return_false' );
$posts[0]->post_status = 'publish';
nocache_headers();
add_action( 'wp_head', static function () { echo '' . "\n"; } );
}
return $posts;
},
10,
2
);
}
/**
* Duplicate a post with everything page builders need.
*
* @param array $a Args.
* @return array|\WP_Error
*/
public static function duplicate( array $a ) {
global $wpdb;
$src = get_post( (int) $a['id'] );
if ( ! $src ) {
return new \WP_Error( 'not_found', 'Nothing to duplicate with that id.' );
}
$ok = Util::generic_type( $src->post_type );
if ( is_wp_error( $ok ) ) {
return $ok;
}
if ( ! current_user_can( 'edit_post', $src->ID ) ) {
return new \WP_Error( 'forbidden', 'You cannot copy this item.' );
}
$status = (string) ( $a['status'] ?? 'draft' );
$ok = Util::can_write_type( $src->post_type, 0, $status );
if ( is_wp_error( $ok ) ) {
return $ok;
}
$title = isset( $a['title'] ) ? sanitize_text_field( $a['title'] ) : $src->post_title . ' (copy)';
// WooCommerce products: use WooCommerce's own duplicator (variations, stock, lookups).
if ( 'product' === $src->post_type && class_exists( '\WC_Admin_Duplicate_Product' ) && function_exists( 'wc_get_product' ) ) {
$dup = ( new \WC_Admin_Duplicate_Product() )->product_duplicate( wc_get_product( $src->ID ) );
if ( ! $dup ) {
return new \WP_Error( 'duplicate_failed', 'WooCommerce could not duplicate the product.' );
}
$dup->set_name( $title );
$dup->set_status( $status );
if ( ! empty( $a['slug'] ) ) {
$dup->set_slug( sanitize_title( $a['slug'] ) );
}
$dup->save();
\HandyMCP\Tools\History::created_post( $dup->get_id() );
return array( 'id' => $dup->get_id(), 'type' => 'product', 'status' => $status, 'edit_url' => get_edit_post_link( $dup->get_id(), 'raw' ), 'message' => 'Product duplicated with its variations.' );
}
$new = wp_insert_post(
wp_slash(
array(
'post_title' => $title,
'post_content' => $src->post_content,
'post_excerpt' => $src->post_excerpt,
'post_status' => $status,
'post_type' => $src->post_type,
'post_parent' => $src->post_parent,
'menu_order' => $src->menu_order,
'comment_status' => $src->comment_status,
'ping_status' => $src->ping_status,
'post_password' => $src->post_password,
'post_author' => get_current_user_id(),
'post_name' => ! empty( $a['slug'] ) ? sanitize_title( $a['slug'] ) : '',
)
),
true
);
if ( is_wp_error( $new ) ) {
return $new;
}
\HandyMCP\Tools\History::created_post( (int) $new );
// Copy meta byte for byte, so JSON and serialized builder data stay exactly as they are.
$skip = array( '_edit_lock', '_edit_last', '_handymcp_preview', '_wp_attached_file', '_wp_attachment_metadata', '_wp_old_slug', '_wp_old_date', '_elementor_css', '_elementor_element_cache', '_elementor_page_assets', '_wp_trash_meta_status', '_wp_trash_meta_time', '_elementor_conditions', '_dp_original', '_icl_lang_duplicate_of' );
$in = implode( ',', array_fill( 0, count( $skip ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- byte-exact meta copy; $in is placeholders built from count($skip).
$wpdb->query( $wpdb->prepare( "INSERT INTO %i (post_id, meta_key, meta_value) SELECT %d, meta_key, meta_value FROM %i WHERE post_id = %d AND meta_key NOT IN ($in)", array_merge( array( $wpdb->postmeta, (int) $new, $wpdb->postmeta, $src->ID ), $skip ) ) );
foreach ( get_object_taxonomies( $src->post_type ) as $tax ) {
$terms = wp_get_object_terms( $src->ID, $tax, array( 'fields' => 'ids' ) );
if ( ! is_wp_error( $terms ) && $terms ) {
wp_set_object_terms( (int) $new, $terms, $tax );
}
}
clean_post_cache( (int) $new );
$builder = Util::builder_of( (int) $new );
$warnings = array();
if ( 'elementor' === $builder && class_exists( '\Elementor\Plugin' ) ) {
Elementor::clear_post_css( (int) $new );
try {
$css = \Elementor\Core\Files\CSS\Post::create( (int) $new );
$css->update();
} catch ( \Throwable $e ) {
$warnings[] = 'Elementor CSS will be generated on first view.';
}
}
if ( 'elementor_library' === $src->post_type && get_post_meta( $src->ID, '_elementor_conditions', true ) ) {
$warnings[] = 'Display conditions were not copied, so the copy does not replace the original on the site. Set them with elementor_save_template when ready.';
}
return array(
'id' => (int) $new,
'type' => $src->post_type,
'status' => $status,
'builder' => $builder,
'url' => get_permalink( (int) $new ),
'edit_url' => get_edit_post_link( (int) $new, 'raw' ),
'warnings' => $warnings,
'message' => 'Duplicated "' . $src->post_title . '" as "' . $title . '" (' . $status . ').',
);
}
/**
* The detailed style guide.
*
* @param array $a Args.
* @return array
*/
public static function style_guide( array $a ): array {
$guide = \HandyMCP\Support\SiteBrief::guide();
return '' === $guide ? array( 'guide' => '', 'message' => 'The site owner has not written a detailed style guide. Follow the site brief.' ) : array( 'guide' => $guide, 'message' => 'Follow this guide together with the site brief for everything you write or change on this site.' );
}
/**
* Site info.
*
* @param array $a Args.
* @param \HandyMCP\Auth\Identity|null $identity Caller.
* @return array
*/
public static function site_info( array $a, $identity = null ): array {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
// CapGuard hides admin caps during requests, so read the account's stored role caps.
$account = get_userdata( get_current_user_id() );
$admin = $account && ! empty( $account->allcaps['manage_options'] );
$active = (array) get_option( 'active_plugins', array() );
$plugins = array();
foreach ( get_plugins() as $file => $data ) {
if ( in_array( $file, $active, true ) || is_plugin_active_for_network( $file ) ) {
if ( $admin ) {
$plugins[] = array( 'name' => $data['Name'], 'version' => $data['Version'], 'file' => $file );
}
}
}
$theme = wp_get_theme();
$types = array();
foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $pt ) {
$types[] = array( 'name' => $pt->name, 'label' => $pt->labels->name );
}
$groups = array();
foreach ( Plugin::instance()->container()->get( 'registry' )->groups() as $g ) {
if ( empty( $g['detected'] ) ) {
continue;
}
$row = array( 'id' => $g['id'], 'label' => $g['label'], 'access' => $g['mode'] );
$pl = (array) $g['plugins'];
$names = array_values( array_diff( array_map( 'strval', $pl && array_keys( $pl ) === range( 0, count( $pl ) - 1 ) ? $pl : array_keys( $pl ) ), array( 'WordPress', 'Built in', 'Block editor' ) ) );
if ( $names && $admin ) {
$row['plugins'] = $names;
}
if ( ! empty( $g['notes'] ) ) {
$row['notes'] = $g['notes'];
}
$groups[] = $row;
}
$user = wp_get_current_user();
$out = array(
'site' => array( 'name' => get_bloginfo( 'name' ), 'tagline' => get_bloginfo( 'description' ), 'url' => home_url( '/' ), 'language' => get_locale(), 'timezone' => wp_timezone_string() ),
'wordpress' => get_bloginfo( 'version' ),
'theme' => array( 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), 'parent' => $theme->parent() ? $theme->parent()->get( 'Name' ) : null, 'block_theme' => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ),
'front_page' => array( 'show_on_front' => get_option( 'show_on_front' ), 'page_on_front' => (int) get_option( 'page_on_front' ), 'page_for_posts' => (int) get_option( 'page_for_posts' ) ),
'post_types' => $types,
'integrations' => $groups,
'you' => array( 'user' => $user->user_login, 'roles' => $user->roles, 'scopes' => $identity ? $identity->scopes : array() ),
);
$no_tools = $identity instanceof \HandyMCP\Auth\Identity ? Plugin::instance()->container()->get( 'registry' )->no_tools_message( $identity ) : '';
if ( '' !== $no_tools ) {
$out['connection_note'] = $no_tools;
}
$brief = \HandyMCP\Support\SiteBrief::text();
if ( '' !== $brief ) {
$out['site_brief'] = $brief . "\nFollow this brief for everything you write or change on this site.";
}
if ( $admin ) {
$out['plugins'] = $plugins;
$out['php'] = PHP_VERSION;
}
return $out;
}
/**
* Create or update a post from tool args.
*
* @param int $id Post id (0 = create).
* @param array $a Args.
* @return array|\WP_Error
*/
private static function save_post( int $id, array $a ) {
$post = array();
if ( $id ) {
$post['ID'] = $id;
} else {
$post['post_type'] = $a['post_type'];
$post['post_status'] = $a['status'] ?? 'draft';
}
$map = array( 'title' => 'post_title', 'excerpt' => 'post_excerpt', 'status' => 'post_status', 'slug' => 'post_name', 'parent' => 'post_parent', 'menu_order' => 'menu_order', 'date' => 'post_date' );
foreach ( $map as $in => $field ) {
if ( isset( $a[ $in ] ) ) {
$post[ $field ] = 'post_title' === $field ? sanitize_text_field( $a[ $in ] ) : $a[ $in ];
}
}
if ( isset( $a['date'] ) ) {
$post['post_date_gmt'] = get_gmt_from_date( (string) $a['date'] );
$post['edit_date'] = true;
} elseif ( $id && isset( $a['status'] ) && 'publish' === $a['status'] && 'future' === get_post_status( $id ) ) {
$post['post_date'] = current_time( 'mysql' );
$post['post_date_gmt'] = current_time( 'mysql', true );
$post['edit_date'] = true;
}
if ( isset( $a['content'] ) ) {
$post['post_content'] = Util::kses( $a['content'] );
}
if ( isset( $a['template'] ) ) {
$post['page_template'] = 'default' === $a['template'] ? '' : $a['template'];
}
if ( ! empty( $a['parent'] ) ) {
$parent = get_post( (int) $a['parent'] );
if ( ! $parent || ! current_user_can( 'edit_post', $parent->ID ) ) {
return new \WP_Error( 'invalid_parent', 'parent must be an existing item you can edit.' );
}
}
if ( ! empty( $a['meta'] ) ) {
foreach ( array_keys( $a['meta'] ) as $key ) {
$raw = (string) $key;
$clean = sanitize_key( $raw );
if ( '' === $clean || $clean !== $raw ) {
return new \WP_Error( 'invalid_meta_key', 'Custom field names may only use lowercase letters, numbers, dashes and underscores.' );
}
if ( is_protected_meta( $clean, 'post' ) ) {
return new \WP_Error( 'protected_meta', "Custom field $clean is protected. Use the owning plugin's tool." );
}
if ( $id && ! current_user_can( 'edit_post_meta', $id, $clean ) ) {
return new \WP_Error( 'forbidden', "You cannot edit custom field $clean." );
}
}
}
$result = $id ? wp_update_post( wp_slash( Util::keep_template( $post ) ), true ) : wp_insert_post( wp_slash( $post ), true );
if ( is_wp_error( $result ) ) {
return $result;
}
$id = (int) $result;
if ( isset( $a['featured_image'] ) ) {
if ( $a['featured_image'] ) {
if ( ! wp_attachment_is_image( $a['featured_image'] ) ) {
return new \WP_Error( 'invalid_image', 'featured_image must be an image attachment ID.' );
}
set_post_thumbnail( $id, $a['featured_image'] );
} else {
delete_post_thumbnail( $id );
}
}
if ( ! empty( $a['terms'] ) ) {
foreach ( $a['terms'] as $tax => $terms ) {
if ( ! is_object_in_taxonomy( get_post_type( $id ), $tax ) ) {
return new \WP_Error( 'invalid_taxonomy', "$tax does not apply to this post type." );
}
$terms = array_map(
static function ( $t ) use ( $tax ) {
return ( is_int( $t ) || ( is_string( $t ) && ctype_digit( $t ) && get_term( (int) $t, $tax ) instanceof \WP_Term ) ) ? (int) $t : $t;
},
(array) $terms
);
$res = wp_set_object_terms( $id, $terms, $tax );
if ( is_wp_error( $res ) ) {
return $res;
}
}
}
foreach ( (array) ( $a['meta'] ?? array() ) as $key => $value ) {
$key = sanitize_key( (string) $key );
if ( ! current_user_can( 'edit_post_meta', $id, $key ) ) {
return new \WP_Error( 'forbidden', "You cannot edit custom field $key." );
}
update_post_meta( $id, $key, wp_slash( Util::kses_deep( $value ) ) );
}
Util::purge_post( $id );
$p = get_post( $id );
return array_merge( Util::summary( $p ), array( 'message' => ( isset( $post['ID'] ) ? 'Updated.' : 'Created.' ) ) );
}
/**
* Search content and builder data with owner attribution.
*
* @param array $a Args.
* @return array
*/
public static function search( array $a ): array {
global $wpdb;
$like = '%' . $wpdb->esc_like( $a['query'] ) . '%';
$types = ! empty( $a['post_types'] ) ? array_filter( array_map( 'sanitize_key', $a['post_types'] ) ) : array_values( get_post_types( array( 'show_ui' => true ) ) );
$types = array_diff( $types, array( 'revision', 'nav_menu_item' ) );
if ( ! $types ) {
return array( 'hits' => array() );
}
$in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$hits = array();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- full-text search across post types; $in is placeholders built from count($types).
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders built from count($types).
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_type, post_status, post_content FROM %i WHERE post_type IN ($in) AND post_status NOT IN ('auto-draft','inherit') AND (post_title LIKE %s OR post_content LIKE %s) ORDER BY post_modified DESC LIMIT %d", array_merge( array( $wpdb->posts ), $types, array( $like, $like, $a['limit'] ) ) ) );
foreach ( (array) $rows as $row ) {
if ( ! current_user_can( 'edit_post', $row->ID ) ) {
continue;
}
$where = false !== stripos( $row->post_title, $a['query'] ) ? 'title' : 'post_content';
$hits[] = array( 'id' => (int) $row->ID, 'title' => $row->post_title, 'type' => $row->post_type, 'status' => $row->post_status, 'found_in' => $where, 'builder' => Util::builder_of( (int) $row->ID ), 'excerpt' => self::excerpt( 'title' === $where ? $row->post_title : wp_strip_all_tags( $row->post_content ), $a['query'] ) );
}
$builder_meta = array( '_elementor_data' => 'elementor', '_bricks_page_content_2' => 'bricks', '_fl_builder_data' => 'beaver', '_breakdance_data' => 'breakdance' );
foreach ( $builder_meta as $key => $builder ) {
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT m.post_id, m.meta_value, p.post_title, p.post_type, p.post_status FROM {$wpdb->postmeta} m JOIN {$wpdb->posts} p ON p.ID = m.post_id WHERE m.meta_key = %s AND m.meta_value LIKE %s AND p.post_status NOT IN ('auto-draft','inherit') LIMIT %d", $key, $like, $a['limit'] ) );
foreach ( (array) $rows as $row ) {
if ( ! current_user_can( 'edit_post', $row->post_id ) ) {
continue;
}
$owner = $builder;
if ( 'elementor_library' === $row->post_type ) {
$type = get_post_meta( (int) $row->post_id, '_elementor_template_type', true );
$owner = 'elementor template (' . $type . ')';
}
$hits[] = array( 'id' => (int) $row->post_id, 'title' => $row->post_title, 'type' => $row->post_type, 'status' => $row->post_status, 'found_in' => $key, 'builder' => $owner, 'excerpt' => self::excerpt( wp_strip_all_tags( wp_unslash( (string) $row->meta_value ) ), $a['query'] ) );
}
}
// phpcs:enable
return array( 'query' => $a['query'], 'hits' => array_slice( $hits, 0, $a['limit'] ), 'note' => 'Edit builder hits with that builder\'s tools, or wp_edit_text with the listed meta key for a small text change.' );
}
/**
* Text around a match.
*
* @param string $text Text.
* @param string $query Query.
*/
private static function excerpt( string $text, string $query ): string {
$pos = stripos( $text, $query );
if ( false === $pos ) {
return mb_substr( $text, 0, 160 );
}
$start = max( 0, $pos - 70 );
return ( $start ? '...' : '' ) . mb_substr( $text, $start, 180 ) . '...';
}
/**
* Exact text replacement in one field or custom field.
*
* @param array $a Args.
* @return array|\WP_Error
*/
public static function edit_text( array $a ) {
$p = Util::post( $a['id'] );
if ( is_wp_error( $p ) ) {
return $p;
}
$field = $a['field'] ?? '';
$key = $a['meta_key'] ?? '';
if ( '' === $field && '' === $key ) {
$field = 'post_content'; // The usual target: the page or post body.
}
if ( '' !== $field && '' !== $key ) {
return new \WP_Error( 'invalid_target', 'Pass either field or meta_key, not both.' );
}
if ( $key ) {
if ( ! metadata_exists( 'post', $p->ID, $key ) ) {
return new \WP_Error( 'meta_not_found', "Custom field $key does not exist on this post." );
}
// Builder layouts are protected meta, but the post's editors may change them.
$builder_keys = array( '_elementor_data', '_elementor_page_settings', '_bricks_page_content_2', '_fl_builder_data', '_breakdance_data' );
$allowed = in_array( $key, $builder_keys, true ) ? current_user_can( 'edit_post', $p->ID ) : ( ! is_protected_meta( $key, 'post' ) && current_user_can( 'edit_post_meta', $p->ID, $key ) );
if ( ! $allowed ) {
return new \WP_Error( 'forbidden', "You cannot edit custom field $key." );
}
$current = get_post_meta( $p->ID, $key, true );
if ( ! is_string( $current ) ) {
return new \WP_Error( 'not_text', 'This custom field is not stored as text. Use the owning plugin\'s tool.' );
}
if ( is_serialized( $current ) ) {
return new \WP_Error( 'serialized_value', 'This custom field is serialized PHP data. Use the owning plugin\'s tool.' );
}
} else {
$current = (string) get_post_field( $field, $p->ID, 'raw' );
}
$old = (string) $a['old_text'];
$new = (string) $a['new_text'];
if ( $old === $new ) {
return new \WP_Error( 'no_change', 'old_text and new_text are the same.' );
}
// Builder JSON stores "/" as "\/" and quotes escaped: try that form too.
$candidates = array( $old );
if ( $key && ( '[' === substr( ltrim( $current ), 0, 1 ) || '{' === substr( ltrim( $current ), 0, 1 ) ) ) {
$json_old = substr( (string) wp_json_encode( $old ), 1, -1 );
if ( $json_old !== $old ) {
$candidates[] = $json_old;
}
}
$used = null;
$count = 0;
foreach ( $candidates as $i => $c ) {
$count = substr_count( $current, $c );
if ( $count ) {
$used = $i;
break;
}
}
$loose = false;
$pattern = '';
if ( ! $count ) {
// Typography differences are the most common miss: curly vs straight
// quotes, vs space, entities, dashes. Match those loosely.
$pattern = self::loose_pattern( $old, null !== $used || ( $key && null !== json_decode( $current ) ) );
$count = $pattern ? (int) preg_match_all( $pattern, $current ) : 0;
$loose = $count > 0;
}
if ( ! $count ) {
return new \WP_Error( 'no_match', 'old_text was not found. Copy it exactly from the current value (wp_get_post or the builder get tool).' );
}
if ( $count > 1 && empty( $a['replace_all'] ) ) {
return new \WP_Error( 'multiple_matches', "old_text appears $count times. Add surrounding text so it is unique, or set replace_all." );
}
$json_ctx = $key && null !== json_decode( $current );
$escaped = substr( (string) wp_json_encode( $new, JSON_UNESCAPED_UNICODE ), 1, -1 );
$apply = static function ( string $replace ) use ( $loose, $pattern, $current, $candidates, $used ) {
if ( $loose ) {
return (string) preg_replace_callback( $pattern, static function () use ( $replace ) { return $replace; }, $current );
}
return str_replace( $candidates[ $used ], $replace, $current );
};
if ( $json_ctx ) {
// Text inside builder JSON sits in a JSON string, so quotes, line
// breaks and backslashes in the new text must be escaped. Fall back to
// the text as sent only if that is what keeps the JSON valid (the
// match spanned JSON structure on purpose).
$updated = $apply( $escaped );
if ( null === json_decode( $updated, true ) ) {
$updated = $apply( $new );
}
} else {
$updated = $apply( $new );
}
if ( $key ) {
$was_json = null !== json_decode( $current, true );
if ( $was_json && null === json_decode( $updated, true ) ) {
$hint = '_elementor_data' === $key ? ' old_text probably includes part of the JSON structure (quotes, brackets or keys). Pass only the visible text, or change the widget with elementor_update_element.' : ' Pass only the visible text, not the JSON around it.';
return new \WP_Error( 'json_corrupted', 'This edit would break the JSON stored in this field. Nothing was changed.' . $hint );
}
if ( $was_json && ! current_user_can( 'unfiltered_html' ) ) {
$names = static function ( $data ) use ( &$names ): array {
$out = array();
if ( is_array( $data ) ) {
foreach ( $data as $k => $v ) {
if ( is_string( $k ) ) {
$out[ $k ] = true;
}
$out += $names( $v );
}
}
return $out;
};
$added = array_diff_key( $names( json_decode( $updated, true ) ), $names( json_decode( $current, true ) ) );
if ( $added ) {
return new \WP_Error( 'json_keys_added', 'This edit would add new settings (' . implode( ', ', array_slice( array_keys( $added ), 0, 5 ) ) . ') to the stored JSON. Change text only here, or use the builder tools for settings. Nothing was changed.' );
}
}
$updated = $was_json ? Util::kses_json( $updated ) : Util::kses( $updated );
update_post_meta( $p->ID, $key, wp_slash( $updated ) );
if ( get_post_meta( $p->ID, $key, true ) !== $updated ) {
return new \WP_Error( 'update_failed', 'The value could not be saved as sent (a filter changed it).' );
}
if ( '_elementor_data' === $key || '_elementor_page_settings' === $key ) {
Elementor::clear_post_css( $p->ID );
}
} else {
if ( 'post_content' === $field && self::block_problems( $updated ) > self::block_problems( $current ) ) {
return new \WP_Error( 'blocks_broken', 'This edit would break block markup ( comments). Nothing was changed.' );
}
$res = wp_update_post( wp_slash( Util::keep_template( array( 'ID' => $p->ID, $field => $updated ) ) ), true );
if ( is_wp_error( $res ) ) {
return $res;
}
}
Util::purge_post( $p->ID );
$out = array( 'id' => $p->ID, 'target' => $key ? "meta:$key" : $field, 'replaced' => $count, 'message' => "Replaced $count occurrence(s)." );
if ( $loose ) {
$out['note'] = 'Matched ignoring quote, space, dash and entity differences.';
}
if ( ! $key ) {
clean_post_cache( $p->ID );
$saved = (string) get_post_field( $field, $p->ID, 'raw' );
if ( $saved !== $updated ) {
$out['warnings'] = array( 'WordPress changed the text while saving (for example your account may not post unfiltered HTML, so some tags or attributes were removed). Read the post again to see what was stored.' );
}
}
return $out;
}
/**
* Regex that matches text with typographic variants.
*
* @param string $text Text.
* @param bool $json Also match JSON escapes (\u2019, \/).
*/
public static function loose_pattern( string $text, bool $json ): string {
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$u = static function ( string $hex ) use ( $json ): string {
return $json ? '|\\\\u' . $hex : '';
};
$sets = array(
"'" => "(?:'|\x{2018}|\x{2019}|&\#0?39;|&\#821[67];|&[lr]squo;|'" . $u( '2018' ) . $u( '2019' ) . ')',
'"' => '(?:"|\x{201C}|\x{201D}|"|&\#8220;|&\#8221;|&[lr]dquo;' . ( $json ? '|\\\\"' : '' ) . $u( '201c' ) . $u( '201d' ) . ')',
' ' => '(?:\s|\x{00A0}| |&\#160;' . $u( '00a0' ) . ')+',
'-' => '(?:-|\x{2013}|\x{2014}|–|—|&\#821[12];' . $u( '2013' ) . $u( '2014' ) . ')',
'&' => '(?:&|&\#038;|&)',
'/' => $json ? '(?:\\\\/|/)' : '/',
'...' => '(?:\.\.\.|\x{2026}|…|&\#8230;' . $u( '2026' ) . ')',
);
$map = array(
"\u{2018}" => "'", "\u{2019}" => "'", "\u{201C}" => '"', "\u{201D}" => '"',
"\u{2013}" => '-', "\u{2014}" => '-', "\u{00A0}" => ' ', "\u{2026}" => '...',
);
$text = strtr( $text, $map );
$out = '';
$len = strlen( $text );
$prev_space = false;
for ( $i = 0; $i < $len; $i++ ) {
if ( '...' === substr( $text, $i, 3 ) ) {
$out .= $sets['...'];
$i += 2;
$prev_space = false;
continue;
}
$ch = $text[ $i ];
if ( ctype_space( $ch ) ) {
if ( ! $prev_space ) {
$out .= $sets[' '];
}
$prev_space = true;
continue;
}
$prev_space = false;
$out .= isset( $sets[ $ch ] ) ? $sets[ $ch ] : preg_quote( $ch, '#' );
}
if ( '' === trim( $text ) || strlen( $out ) > 20000 ) {
return '';
}
return '#' . $out . '#u';
}
/**
* Count block markup problems (unbalanced comments, bad attributes).
*
* @param string $content Content.
*/
private static function block_problems( string $content ): int {
if ( false === strpos( $content, '/s', $content );
$void = preg_match_all( '//s', $content );
$close = preg_match_all( '//', $content );
$broken = 0;
$walk = static function ( array $blocks ) use ( &$walk, &$broken ) {
foreach ( $blocks as $b ) {
if ( $b['blockName'] && ! is_array( $b['attrs'] ) ) {
++$broken;
}
$walk( $b['innerBlocks'] );
}
};
$walk( parse_blocks( $content ) );
return abs( $open - $close ) + $broken + ( $void > $open ? 1 : 0 );
}
/**
* Media tools.
*
* @param Registry $r Registry.
*/
private static function register_media( Registry $r ): void {
$r->add(
array(
'name' => 'wp_list_media',
'group' => 'wordpress',
'title' => 'List media',
'cap' => 'upload_files',
'input' => array(
'properties' => array(
'search' => array( 'type' => 'string' ),
'mime_type' => array( 'type' => 'string', 'description' => 'e.g. image, image/png, application/pdf' ),
'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30 ),
'page' => array( 'type' => 'integer', 'minimum' => 1, 'default' => 1 ),
),
),
'run' => static function ( array $a ) {
$q = new \WP_Query( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 's' => $a['search'] ?? '', 'post_mime_type' => $a['mime_type'] ?? '', 'posts_per_page' => $a['per_page'], 'paged' => $a['page'] ) );
return array( 'items' => array_map( array( __CLASS__, 'media_item' ), $q->posts ), 'total' => (int) $q->found_posts );
},
)
);
$r->add(
array(
'name' => 'wp_upload_media',
'group' => 'wordpress',
'title' => 'Upload an image',
'description' => 'Add an image to the media library from a public URL, from a file the user attached in the chat (file), or from base64 image data up to about 1.4 MB. Can resize (max_width) and convert to WebP or JPEG (format) before saving, to keep pages fast. Private and internal addresses are refused. Returns the attachment ID and URL to use in pages.',
'write' => true,
'cap' => 'upload_files',
'input' => array(
'properties' => array(
'url' => array( 'type' => 'string', 'maxLength' => 2000 ),
'file' => array(
'type' => 'object',
'description' => 'An image the user attached in the chat, as passed by the AI app (download_url and optional name).',
'properties' => array(
'download_url' => array( 'type' => 'string', 'maxLength' => 4000 ),
'file_id' => array( 'type' => 'string', 'maxLength' => 200 ),
'mime_type' => array( 'type' => 'string', 'maxLength' => 100 ),
'name' => array( 'type' => 'string', 'maxLength' => 200 ),
),
'additionalProperties' => true,
),
'max_width' => array( 'type' => 'integer', 'minimum' => 100, 'maximum' => 5000, 'description' => 'Scale the image down to this width in pixels. Smaller images are not enlarged.' ),
'format' => array( 'type' => 'string', 'enum' => array( 'webp', 'jpg', 'png' ), 'description' => 'Convert the image to this format before saving.' ),
'quality' => array( 'type' => 'integer', 'minimum' => 40, 'maximum' => 95, 'description' => 'Compression quality for WebP and JPEG (default 82).' ),
'data' => array( 'type' => 'string', 'maxLength' => 2000000, 'description' => 'Base64 image data or a data:image/...;base64, URL, up to about 1.4 MB of image. Use instead of url.' ),
'filename' => array( 'type' => 'string', 'maxLength' => 200, 'description' => 'File name for data uploads, e.g. team-photo.jpg.' ),
'title' => array( 'type' => 'string', 'maxLength' => 200 ),
'alt_text' => array( 'type' => 'string', 'maxLength' => 300 ),
'caption' => array( 'type' => 'string' ),
'post_id' => array( 'type' => 'integer', 'minimum' => 0, 'description' => 'Attach to this post.' ),
),
),
'meta' => array( 'openai/fileParams' => array( 'file' ) ),
'run' => array( __CLASS__, 'upload_media' ),
)
);
$r->add(
array(
'name' => 'wp_update_media',
'group' => 'wordpress',
'title' => 'Update media details',
'description' => 'Change title, alt text, caption or description of a media item.',
'write' => true,
'cap' => 'upload_files',
'input' => array(
'required' => array( 'id' ),
'properties' => array(
'id' => Util::id( 'Attachment ID' ),
'title' => array( 'type' => 'string' ),
'alt_text' => array( 'type' => 'string' ),
'caption' => array( 'type' => 'string' ),
'description' => array( 'type' => 'string' ),
),
),
'run' => static function ( array $a ) {
$p = Util::post( $a['id'], array( 'attachment' ) );
if ( is_wp_error( $p ) ) {
return $p;
}
$u = array( 'ID' => $p->ID );
if ( isset( $a['title'] ) ) {
$u['post_title'] = sanitize_text_field( $a['title'] );
}
if ( isset( $a['caption'] ) ) {
$u['post_excerpt'] = wp_kses_post( $a['caption'] );
}
if ( isset( $a['description'] ) ) {
$u['post_content'] = wp_kses_post( $a['description'] );
}
if ( count( $u ) > 1 ) {
wp_update_post( wp_slash( $u ) );
}
if ( isset( $a['alt_text'] ) ) {
update_post_meta( $p->ID, '_wp_attachment_image_alt', sanitize_text_field( $a['alt_text'] ) );
}
return self::media_item( get_post( $p->ID ) );
},
)
);
}
/**
* Media item summary.
*
* @param \WP_Post $p Attachment.
* @return array
*/
public static function media_item( \WP_Post $p ): array {
$meta = wp_get_attachment_metadata( $p->ID );
return array(
'id' => $p->ID,
'title' => $p->post_title,
'url' => wp_get_attachment_url( $p->ID ),
'mime' => $p->post_mime_type,
'alt_text' => (string) get_post_meta( $p->ID, '_wp_attachment_image_alt', true ),
'caption' => $p->post_excerpt,
'width' => is_array( $meta ) ? ( $meta['width'] ?? null ) : null,
'height' => is_array( $meta ) ? ( $meta['height'] ?? null ) : null,
);
}
/**
* Is this a public http(s) URL whose host resolves only to public IPs?
*
* @param string $url URL.
*/
public static function public_url( string $url ): bool {
return '' !== self::resolve_public( $url );
}
/**
* Resolve a URL's host once and return one address that is public, or ''.
* Every address the name resolves to must be public.
*
* @param string $url URL.
*/
public static function resolve_public( string $url ): string {
$parts = wp_parse_url( $url );
if ( empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) ) {
return '';
}
$host = trim( $parts['host'], '[]' );
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
$ips = array( $host );
} else {
$ips = (array) gethostbynamel( $host );
if ( function_exists( 'dns_get_record' ) ) {
foreach ( (array) @dns_get_record( $host, DNS_AAAA ) as $r ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! empty( $r['ipv6'] ) ) {
$ips[] = $r['ipv6'];
}
}
}
}
$ips = array_values( array_filter( $ips ) );
if ( ! $ips ) {
return '';
}
foreach ( $ips as $ip ) {
if ( ! self::ip_is_public( (string) $ip ) ) {
return '';
}
}
foreach ( $ips as $ip ) {
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return (string) $ip;
}
}
return (string) $ips[0];
}
/**
* Is an address on the public internet (not private, loopback, link-local,
* shared, multicast, documentation or reserved)?
*
* @param string $ip Address.
*/
public static function ip_is_public( string $ip ): bool {
$ip = trim( $ip, " \t\n\r\0\x0B[]" );
$bin = filter_var( $ip, FILTER_VALIDATE_IP ) ? @inet_pton( $ip ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === $bin || ( 4 !== strlen( $bin ) && 16 !== strlen( $bin ) ) ) {
return false;
}
if ( 16 === strlen( $bin ) ) {
$prefix96 = substr( $bin, 0, 12 );
// IPv4-mapped (::ffff:0:0/96) and IPv4-compatible (::/96): judge the embedded IPv4 address.
if ( str_repeat( "\0", 10 ) . "\xff\xff" === $prefix96 || str_repeat( "\0", 12 ) === $prefix96 ) {
$bin = substr( $bin, 12 );
} else {
$blocked6 = array( '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'fec0::/10', 'ff00::/8', '2001:db8::/32', '2001::/32', '2002::/16', '64:ff9b::/96', '64:ff9b:1::/48', '100::/64' );
foreach ( $blocked6 as $cidr ) {
if ( self::bin_in_cidr( $bin, $cidr ) ) {
return false;
}
}
return true;
}
}
$blocked4 = array( '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4' );
foreach ( $blocked4 as $cidr ) {
if ( self::bin_in_cidr( $bin, $cidr ) ) {
return false;
}
}
return true;
}
/**
* Is a packed address (inet_pton output) inside a CIDR range of the same family?
*
* @param string $bin Packed address.
* @param string $cidr Range, e.g. 10.0.0.0/8 or fc00::/7.
*/
private static function bin_in_cidr( string $bin, string $cidr ): bool {
list( $net, $bits ) = explode( '/', $cidr );
$net = inet_pton( $net );
$bits = (int) $bits;
if ( false === $net || strlen( $net ) !== strlen( $bin ) ) {
return false;
}
$full = intdiv( $bits, 8 );
if ( substr( $bin, 0, $full ) !== substr( $net, 0, $full ) ) {
return false;
}
$rest = $bits % 8;
if ( 0 === $rest ) {
return true;
}
$mask = ( 0xff << ( 8 - $rest ) ) & 0xff;
return ( ord( $bin[ $full ] ) & $mask ) === ( ord( $net[ $full ] ) & $mask );
}
/**
* Download a public URL to a temp file. Every redirect hop is checked and
* the connection is pinned to the checked IP (no DNS rebinding).
*
* @param string $url URL.
* @param int $max_bytes Largest accepted file in bytes.
* @param string $host_pattern Optional regex every hop's host must match (https only when set).
* @return string|\WP_Error Temp file path.
*/
public static function safe_download( string $url, int $max_bytes = 0, string $host_pattern = '' ) {
$max_bytes = $max_bytes > 0 ? $max_bytes : 25 * MB_IN_BYTES;
for ( $hop = 0; $hop < 5; $hop++ ) {
if ( '' !== $host_pattern && ( 'https' !== strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ) || ! preg_match( $host_pattern, strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) ) ) ) {
return new \WP_Error( 'blocked_url', 'The download left the allowed host.' );
}
// Resolve once, check, and connect to exactly that address.
$ip = self::resolve_public( $url );
if ( '' === $ip ) {
return new \WP_Error( 'blocked_url', 'Only public http(s) URLs can be downloaded.' );
}
if ( ! function_exists( 'curl_init' ) || ! defined( 'CURLOPT_RESOLVE' ) ) {
return new \WP_Error( 'no_curl', 'Downloading remote files safely needs the PHP cURL extension.' );
}
$host = trim( (string) wp_parse_url( $url, PHP_URL_HOST ), '[]' );
$port = (int) ( wp_parse_url( $url, PHP_URL_PORT ) ? wp_parse_url( $url, PHP_URL_PORT ) : ( 'https' === wp_parse_url( $url, PHP_URL_SCHEME ) ? 443 : 80 ) );
$pinned = false;
$pin = static function ( $handle ) use ( $host, $port, $ip, &$pinned ) {
$pinned = true;
curl_setopt( $handle, CURLOPT_RESOLVE, array( $host . ':' . $port . ':' . ( false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip ) ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
};
$tmp = wp_tempnam( $url );
add_action( 'http_api_curl', $pin );
$res = wp_safe_remote_get( $url, array( 'timeout' => max( 30, min( 300, (int) ( $max_bytes / MB_IN_BYTES ) ) ), 'redirection' => 0, 'stream' => true, 'filename' => $tmp, 'limit_response_size' => $max_bytes ) );
remove_action( 'http_api_curl', $pin );
if ( is_wp_error( $res ) ) {
wp_delete_file( $tmp );
return $res;
}
if ( ! $pinned ) {
// The request did not go through cURL, so the checked address was not enforced.
wp_delete_file( $tmp );
return new \WP_Error( 'no_curl', 'Downloading remote files safely needs the PHP cURL transport.' );
}
$code = (int) wp_remote_retrieve_response_code( $res );
if ( $code >= 300 && $code < 400 ) {
wp_delete_file( $tmp );
$loc = (string) wp_remote_retrieve_header( $res, 'location' );
if ( '' === $loc ) {
break;
}
$url = 0 === strpos( $loc, '/' ) ? preg_replace( '#^(https?://[^/]+).*$#', '$1', $url ) . $loc : $loc;
continue;
}
if ( 200 !== $code ) {
wp_delete_file( $tmp );
return new \WP_Error( 'download_failed', "The URL returned HTTP $code." );
}
clearstatcache( true, $tmp );
if ( (int) @filesize( $tmp ) >= $max_bytes ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
wp_delete_file( $tmp );
return new \WP_Error( 'too_large', 'The file is larger than the allowed ' . size_format( $max_bytes ) . '.' );
}
return $tmp;
}
return new \WP_Error( 'download_failed', 'Too many redirects.' );
}
/**
* Sideload an image from a public URL.
*
* @param array $a Args.
* @return array|\WP_Error
*/
public static function upload_media( array $a ) {
if ( ! empty( $a['file'] ) && is_array( $a['file'] ) && empty( $a['url'] ) ) {
$a['url'] = (string) ( $a['file']['download_url'] ?? '' );
if ( empty( $a['filename'] ) && ! empty( $a['file']['name'] ) ) {
$a['filename'] = (string) $a['file']['name'];
}
}
$data = (string) ( $a['data'] ?? '' );
if ( '' === $data && empty( $a['url'] ) ) {
return new \WP_Error( 'missing', 'Pass url or data.' );
}
if ( '' === $data && ! self::public_url( $a['url'] ) ) {
return new \WP_Error( 'blocked_url', 'Only public http(s) URLs can be downloaded.' );
}
if ( ! empty( $a['post_id'] ) && ( ! get_post( (int) $a['post_id'] ) || ! current_user_can( 'edit_post', (int) $a['post_id'] ) ) ) {
return new \WP_Error( 'forbidden', 'You cannot attach media to that post.' );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
if ( '' !== $data ) {
$data = (string) preg_replace( '#^data:[^;,]+;base64,#i', '', trim( $data ) );
$bytes = base64_decode( (string) preg_replace( '/\s+/', '', $data ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $bytes || strlen( $bytes ) < 20 || strlen( $bytes ) > 1500000 ) {
return new \WP_Error( 'bad_data', 'The image data is not valid base64 or is too large.' );
}
global $wp_filesystem;
if ( ! WP_Filesystem() || ! $wp_filesystem ) {
return new \WP_Error( 'filesystem', 'The image could not be saved: WordPress cannot write files on this server without FTP credentials.' );
}
$tmp = wp_tempnam( 'handymcp-upload' );
if ( ! $tmp || ! $wp_filesystem->put_contents( $tmp, $bytes, FS_CHMOD_FILE ) ) {
if ( $tmp ) {
wp_delete_file( $tmp );
}
return new \WP_Error( 'write_failed', 'The image could not be saved to a temporary file.' );
}
$name = sanitize_file_name( (string) ( $a['filename'] ?? ( $a['title'] ?? 'image' ) ) );
} else {
$tmp = self::safe_download( (string) $a['url'] );
if ( is_wp_error( $tmp ) ) {
return $tmp;
}
$readable = sanitize_title( (string) ( ! empty( $a['alt_text'] ) ? $a['alt_text'] : ( $a['title'] ?? '' ) ) );
if ( empty( $a['filename'] ) && '' !== $readable ) {
// Readable, SEO-friendly file name from the alt text or title.
$a['filename'] = substr( $readable, 0, 60 );
}
$name = ! empty( $a['filename'] ) ? sanitize_file_name( (string) $a['filename'] ) : sanitize_file_name( basename( (string) wp_parse_url( $a['url'], PHP_URL_PATH ) ) );
}
$mime = function_exists( 'wp_get_image_mime' ) ? wp_get_image_mime( $tmp ) : '';
if ( ! $mime || 0 !== strpos( $mime, 'image/' ) ) {
wp_delete_file( $tmp );
return new \WP_Error( 'not_an_image', '' !== $data ? 'The data is not an image this site can store (JPEG, PNG, GIF, WebP or AVIF).' : 'The file is not an image this site can store (JPEG, PNG, GIF, WebP or AVIF).', array( 'retryable' => false ) );
}
if ( ! empty( $a['max_width'] ) || ! empty( $a['format'] ) ) {
$mime = self::optimize_image( $tmp, $mime, (int) ( $a['max_width'] ?? 0 ), (string) ( $a['format'] ?? '' ), (int) ( $a['quality'] ?? 82 ) );
$name = (string) preg_replace( '/\.(jpe?g|png|gif|webp|avif)$/i', '', $name );
}
$ext_map = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif' );
$ext = $ext_map[ $mime ] ?? 'jpg';
if ( ! preg_match( '/\.(jpe?g|png|gif|webp|avif)$/i', $name ) ) {
$base = ! empty( $a['filename'] ) ? pathinfo( $name, PATHINFO_FILENAME ) : ( $a['title'] ?? pathinfo( $name, PATHINFO_FILENAME ) );
$name = sanitize_file_name( ( $base ? $base : 'image' ) . '.' . $ext );
}
if ( ! empty( $a['format'] ) ) {
add_filter( 'image_editor_output_format', '__return_empty_array', 999 );
}
$id = media_handle_sideload( array( 'name' => $name, 'tmp_name' => $tmp ), (int) ( $a['post_id'] ?? 0 ), $a['title'] ?? null );
remove_filter( 'image_editor_output_format', '__return_empty_array', 999 );
if ( is_wp_error( $id ) && 'upload_error' === $id->get_error_code() ) {
/* The site refused the file, often a file type rule from another plugin: say so, so the AI does not retry. */
$id = new \WP_Error( 'unsupported_file_type', 'This site refused the file: ' . $id->get_error_message(), array( 'retryable' => false ) );
}
if ( is_wp_error( $id ) ) {
wp_delete_file( $tmp );
return $id;
}
if ( ! empty( $a['alt_text'] ) ) {
update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $a['alt_text'] ) );
}
if ( ! empty( $a['caption'] ) ) {
wp_update_post( array( 'ID' => $id, 'post_excerpt' => wp_kses_post( $a['caption'] ) ) );
}
return self::media_item( get_post( $id ) );
}
/**
* Resize and convert an uploaded image in place. Returns the new MIME type.
*
* @param string $tmp Temporary file.
* @param string $mime Current MIME type.
* @param int $width Maximum width, 0 to keep.
* @param string $format webp, jpg, png or empty to keep.
* @param int $quality Quality 40 to 95.
*/
private static function optimize_image( string $tmp, string $mime, int $width, string $format, int $quality ): string {
if ( 'image/gif' === $mime ) {
return $mime;
}
$editor = wp_get_image_editor( $tmp );
if ( is_wp_error( $editor ) ) {
return $mime;
}
$size = $editor->get_size();
if ( $width > 0 && ! empty( $size['width'] ) && $size['width'] > $width ) {
$editor->resize( $width, null );
}
$editor->set_quality( max( 40, min( 95, $quality ) ) );
$types = array( 'webp' => 'image/webp', 'jpg' => 'image/jpeg', 'png' => 'image/png' );
$target = $types[ $format ] ?? $mime;
if ( ! wp_image_editor_supports( array( 'mime_type' => $target ) ) ) {
$target = $mime;
}
// The format asked for wins over a site rule that converts every upload (for example to WebP).
if ( '' !== $format ) {
add_filter( 'image_editor_output_format', '__return_empty_array', 999 );
}
$saved = $editor->save( $tmp . '.out', $target );
remove_filter( 'image_editor_output_format', '__return_empty_array', 999 );
if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! file_exists( $saved['path'] ) ) {
return $mime;
}
$target = ! empty( $saved['mime-type'] ) ? (string) $saved['mime-type'] : $target;
global $wp_filesystem;
if ( ( WP_Filesystem() && $wp_filesystem && $wp_filesystem->move( $saved['path'], $tmp, true ) ) ) {
return $target;
}
wp_delete_file( $saved['path'] );
return $mime;
}
/**
* Does a menu item belong to this menu?
*
* @param int $item_id Menu item post id.
* @param int $menu_id Menu term id.
*/
private static function item_in_menu( int $item_id, int $menu_id ): bool {
$menus = wp_get_object_terms( $item_id, 'nav_menu', array( 'fields' => 'ids' ) );
return ! is_wp_error( $menus ) && in_array( $menu_id, array_map( 'intval', (array) $menus ), true );
}
/**
* Menu tools.
*
* @param Registry $r Registry.
*/
private static function register_menus( Registry $r ): void {
$r->add(
array(
'name' => 'wp_get_menus',
'group' => 'wordpress',
'title' => 'List menus and their items',
'description' => 'Classic navigation menus with their items and theme locations. (Block themes use navigation blocks instead: see gutenberg tools.)',
'cap' => 'edit_theme_options',
'input' => array( 'properties' => array( 'menu' => array( 'type' => array( 'integer', 'string' ), 'description' => 'Menu ID, slug or name. Omit to list all menus.' ) ) ),
'run' => static function ( array $a ) {
$locations = get_nav_menu_locations();
$registered = get_registered_nav_menus();
$menus = isset( $a['menu'] ) ? array_filter( array( wp_get_nav_menu_object( $a['menu'] ) ) ) : wp_get_nav_menus();
$out = array();
foreach ( $menus as $m ) {
$items = array();
foreach ( (array) wp_get_nav_menu_items( $m->term_id ) as $it ) {
$items[] = array( 'id' => $it->ID, 'title' => $it->title, 'url' => $it->url, 'parent' => (int) $it->menu_item_parent, 'order' => $it->menu_order, 'type' => $it->type, 'object' => $it->object, 'object_id' => (int) $it->object_id );
}
$out[] = array( 'id' => $m->term_id, 'name' => $m->name, 'slug' => $m->slug, 'locations' => array_keys( $locations, $m->term_id, true ), 'items' => $items );
}
return array( 'menus' => $out, 'theme_locations' => $registered );
},
)
);
$r->add(
array(
'name' => 'wp_save_menu_item',
'group' => 'wordpress',
'title' => 'Add or update a menu item',
'description' => 'Add an item to a menu (omit item_id) or update one. type "custom" needs url; "post_type" needs object (page, post...) and object_id; "taxonomy" needs object (category...) and object_id.',
'write' => true,
'cap' => 'edit_theme_options',
'input' => array(
'required' => array( 'menu' ),
'properties' => array(
'menu' => array( 'type' => array( 'integer', 'string' ), 'description' => 'Menu ID, slug or name. A new menu is created if the name does not exist.' ),
'item_id' => array( 'type' => 'integer', 'minimum' => 1 ),
'title' => array( 'type' => 'string' ),
'type' => array( 'type' => 'string', 'enum' => array( 'custom', 'post_type', 'taxonomy' ), 'default' => 'custom' ),
'object' => array( 'type' => 'string' ),
'object_id' => array( 'type' => 'integer', 'minimum' => 0 ),
'url' => array( 'type' => 'string' ),
'parent' => array( 'type' => 'integer', 'minimum' => 0, 'description' => 'Parent menu item ID for a submenu.' ),
'position' => array( 'type' => 'integer', 'minimum' => 0 ),
),
),
'run' => static function ( array $a ) {
$menu = wp_get_nav_menu_object( $a['menu'] );
if ( ! $menu ) {
if ( ! is_string( $a['menu'] ) ) {
return new \WP_Error( 'menu_not_found', 'Menu not found.' );
}
$new = wp_create_nav_menu( sanitize_text_field( $a['menu'] ) );
if ( is_wp_error( $new ) ) {
return $new;
}
$menu = wp_get_nav_menu_object( $new );
}
$args = array( 'menu-item-status' => 'publish' );
if ( ! empty( $a['item_id'] ) ) {
$item = get_post( $a['item_id'] );
if ( ! $item || ! is_nav_menu_item( $item->ID ) || ! self::item_in_menu( $item->ID, (int) $menu->term_id ) ) {
return new \WP_Error( 'item_not_found', 'Menu item not found in this menu.' );
}
$cur = wp_setup_nav_menu_item( $item );
$args = array_merge(
$args,
array(
'menu-item-title' => $cur->title,
'menu-item-url' => $cur->url,
'menu-item-type' => $cur->type,
'menu-item-object' => $cur->object,
'menu-item-object-id' => $cur->object_id,
'menu-item-parent-id' => $cur->menu_item_parent,
'menu-item-position' => $cur->menu_order,
)
);
}
$map = array( 'title' => 'menu-item-title', 'type' => 'menu-item-type', 'object' => 'menu-item-object', 'object_id' => 'menu-item-object-id', 'url' => 'menu-item-url', 'parent' => 'menu-item-parent-id', 'position' => 'menu-item-position' );
foreach ( $map as $k => $field ) {
if ( isset( $a[ $k ] ) && ( 'type' !== $k || empty( $a['item_id'] ) || isset( $a['object'] ) || isset( $a['url'] ) ) ) {
$args[ $field ] = 'url' === $k ? esc_url_raw( $a[ $k ] ) : $a[ $k ];
}
}
if ( 'custom' === ( $args['menu-item-type'] ?? 'custom' ) && empty( $args['menu-item-url'] ) ) {
return new \WP_Error( 'missing_url', 'Custom links need a url.' );
}
if ( ! empty( $args['menu-item-parent-id'] ) && ( ! is_nav_menu_item( (int) $args['menu-item-parent-id'] ) || ! self::item_in_menu( (int) $args['menu-item-parent-id'], (int) $menu->term_id ) ) ) {
return new \WP_Error( 'invalid_parent', 'parent must be an item of the same menu.' );
}
if ( 'post_type' === ( $args['menu-item-type'] ?? '' ) ) {
$target = get_post( (int) ( $args['menu-item-object-id'] ?? 0 ) );
if ( ! $target || ( 'publish' !== $target->post_status && ! current_user_can( 'read_post', $target->ID ) ) ) {
return new \WP_Error( 'invalid_object', 'object_id does not exist.' );
}
$args['menu-item-object'] = $target->post_type;
}
if ( 'taxonomy' === ( $args['menu-item-type'] ?? '' ) ) {
$term = get_term( (int) ( $args['menu-item-object-id'] ?? 0 ) );
if ( ! $term || is_wp_error( $term ) ) {
return new \WP_Error( 'invalid_object', 'object_id is not an existing term.' );
}
$args['menu-item-object'] = $term->taxonomy;
}
$id = wp_update_nav_menu_item( $menu->term_id, (int) ( $a['item_id'] ?? 0 ), $args );
if ( is_wp_error( $id ) ) {
return $id;
}
return array( 'menu_id' => $menu->term_id, 'item_id' => $id, 'message' => empty( $a['item_id'] ) ? 'Menu item added.' : 'Menu item updated.' );
},
)
);
$r->add(
array(
'name' => 'wp_delete_menu_item',
'group' => 'wordpress',
'title' => 'Remove a menu item',
'description' => 'Permanently remove one item from a menu. Its sub-items move up one level. Ask the user first.',
'write' => true,
'confirm' => true,
'cap' => 'edit_theme_options',
'input' => array( 'required' => array( 'item_id' ), 'properties' => array( 'item_id' => Util::id( 'Menu item ID' ) ) ),
'run' => static function ( array $a ) {
$item = get_post( $a['item_id'] );
if ( ! $item || 'nav_menu_item' !== $item->post_type ) {
return new \WP_Error( 'item_not_found', 'Menu item not found.' );
}
wp_delete_post( $item->ID, true );
return array( 'item_id' => $a['item_id'], 'message' => 'Menu item removed.' );
},
)
);
$r->add(
array(
'name' => 'wp_assign_menu_location',
'group' => 'wordpress',
'title' => 'Assign a menu to a theme location',
'write' => true,
'cap' => 'edit_theme_options',
'input' => array(
'required' => array( 'location' ),
'properties' => array(
'location' => array( 'type' => 'string' ),
'menu' => array( 'type' => array( 'integer', 'string' ), 'description' => 'Menu ID/slug/name, or 0 to unassign.' ),
),
),
'run' => static function ( array $a ) {
$registered = get_registered_nav_menus();
if ( ! isset( $registered[ $a['location'] ] ) ) {
return new \WP_Error( 'invalid_location', 'Unknown location. Registered: ' . implode( ', ', array_keys( $registered ) ) );
}
$locations = get_theme_mod( 'nav_menu_locations', array() );
if ( empty( $a['menu'] ) ) {
unset( $locations[ $a['location'] ] );
} else {
$menu = wp_get_nav_menu_object( $a['menu'] );
if ( ! $menu ) {
return new \WP_Error( 'menu_not_found', 'Menu not found.' );
}
$locations[ $a['location'] ] = $menu->term_id;
}
set_theme_mod( 'nav_menu_locations', $locations );
return array( 'locations' => get_nav_menu_locations() );
},
)
);
}
}