add( array( 'name' => 'wp_delete_media', 'group' => 'wordpress', 'title' => 'Delete a media file', 'description' => 'Permanently delete a media item and its files. Check where it is used first (wp_search with its file name).', 'write' => true, 'confirm' => true, 'cap' => 'delete_posts', 'input' => array( 'required' => array( 'id' ), 'properties' => array( 'id' => Util::id( 'Attachment ID' ) ) ), 'run' => static function ( array $a ) { $p = Util::post( $a['id'], array( 'attachment' ), 'delete' ); if ( is_wp_error( $p ) ) { return $p; } return wp_delete_attachment( $p->ID, true ) ? array( 'deleted' => $p->ID ) : new \WP_Error( 'delete_failed', 'Could not delete.' ); }, ) ); $r->add( array( 'name' => 'wp_delete_menu', 'group' => 'wordpress', 'title' => 'Delete a menu', 'description' => 'Delete a navigation menu and its items. This cannot be undone, so check with the user first.', 'write' => true, 'confirm' => true, 'cap' => 'edit_theme_options', 'input' => array( 'required' => array( 'menu' ), 'properties' => array( 'menu' => array( 'type' => array( 'integer', 'string' ) ) ) ), 'run' => static function ( array $a ) { $m = wp_get_nav_menu_object( $a['menu'] ); if ( ! $m ) { return new \WP_Error( 'menu_not_found', 'Menu not found.' ); } // Not recorded for undo: the menu and its items cannot be rebuilt from the history. $res = \HandyMCP\Tools\History::without( static function () use ( $m ) { return wp_delete_nav_menu( $m->term_id ); } ); return is_wp_error( $res ) ? $res : array( 'deleted' => $m->name, 'message' => 'Menu deleted. This cannot be undone.' ); }, ) ); if ( $r->detected( 'elementor' ) ) { $r->add( array( 'name' => 'elementor_copy_styles', 'group' => 'elementor', 'title' => 'Copy styles between elements', 'description' => 'Copy the style settings (Style and Advanced tabs: colors, typography, spacing, borders, backgrounds) of one element onto other elements of the same widget type, on the same or other pages. Content (texts, links, images) is not touched.', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'source_page', 'source_element', 'targets' ), 'properties' => array( 'source_page' => Util::id(), 'source_element' => array( 'type' => 'string' ), 'targets' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'required' => array( 'page', 'element' ), 'properties' => array( 'page' => array( 'type' => 'integer' ), 'element' => array( 'type' => 'string' ) ), 'additionalProperties' => false ) ), ), ), 'run' => static function ( array $a, $identity = null ) { $src = Elementor::get_page( array( 'id' => $a['source_page'], 'element_id' => $a['source_element'], 'format' => 'full' ) ); if ( is_wp_error( $src ) ) { return $src; } if ( empty( $src['element'] ) || ! is_array( $src['element'] ) || empty( $src['element']['elType'] ) ) { return new \WP_Error( 'element_not_found', 'The source element was not found on that page.' ); } $el = $src['element']; $type = (string) ( $el['widgetType'] ?? $el['elType'] ); $obj = 'widget' === $el['elType'] ? \Elementor\Plugin::$instance->widgets_manager->get_widget_types( $type ) : \Elementor\Plugin::$instance->elements_manager->get_element_types( $type ); if ( ! $obj ) { return new \WP_Error( 'unknown_widget', 'Elementor does not know the element type ' . $type . ' (is its add-on active?).' ); } $style = array(); foreach ( $obj->get_controls() as $name => $c ) { if ( in_array( $c['tab'] ?? '', array( 'style', 'advanced' ), true ) ) { $style[ $name ] = true; } } $copy = array_intersect_key( (array) $el['settings'], $style ); foreach ( (array) $el['settings'] as $k => $v ) { if ( '__globals__' === $k ) { $copy[ $k ] = array_intersect_key( (array) $v, $style ); } } $out = array(); foreach ( $a['targets'] as $t ) { $res = Plugin_Call::run( 'elementor_update_element', array( 'id' => (int) $t['page'], 'element_id' => $t['element'], 'settings' => $copy ), $identity ); $out[ $t['page'] . '/' . $t['element'] ] = isset( $res['error'] ) ? $res['error']['message'] : 'copied'; } return array( 'style_keys' => array_keys( $copy ), 'results' => $out ); }, ) ); $r->add( array( 'name' => 'elementor_widget_usage', 'group' => 'elementor', 'title' => 'Where is a widget used', 'description' => 'Count how often each widget (or one widget type) is used across all Elementor pages and templates, with the pages that use it. Useful before disabling add-on widgets.', 'cap' => 'edit_posts', 'input' => array( 'properties' => array( 'widget_type' => array( 'type' => 'string' ) ) ), 'run' => static function ( array $a ) { global $wpdb; $type = isset( $a['widget_type'] ) ? (string) $a['widget_type'] : ''; // IDs first (preselected with LIKE when one widget type is asked for), then the layouts in batches. if ( '' !== $type ) { $like = '%' . $wpdb->esc_like( '"widgetType":"' . $type . '"' ) . '%'; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_elementor_data' WHERE p.post_status IN ('publish','draft','private') AND p.post_type <> 'revision' AND m.meta_value LIKE %s ORDER BY p.ID", $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } else { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s WHERE p.post_status IN ('publish','draft','private') AND p.post_type <> 'revision' ORDER BY p.ID", '_elementor_data' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } $ids = array_values( array_filter( array_map( 'intval', (array) $ids ), static function ( $id ) { return current_user_can( 'edit_post', $id ); } ) ); $usage = array(); foreach ( array_chunk( $ids, 200 ) as $batch ) { $in = implode( ',', array_fill( 0, count( $batch ), '%d' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title, m.meta_value FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_elementor_data' WHERE p.ID IN ($in)", $batch ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders built from the batch size. foreach ( (array) $rows as $row ) { preg_match_all( '/"widgetType":"([^"]+)"/', (string) $row->meta_value, $m ); foreach ( array_count_values( $m[1] ) as $w => $n ) { if ( '' !== $type && $w !== $type ) { continue; } $usage[ $w ]['count'] = ( $usage[ $w ]['count'] ?? 0 ) + $n; $usage[ $w ]['pages'][ $row->ID ] = $row->post_title; } } unset( $rows ); } uasort( $usage, static function ( $x, $y ) { return $y['count'] <=> $x['count']; } ); return array( 'usage' => $usage ); }, ) ); } } } /** * Call another tool from inside a tool (same user). */ final class Plugin_Call { /** * Run. * * The caller's identity is passed through, so the inner tool gets exactly * the same scope and capability checks as a direct call. * * @param string $tool Tool. * @param array $args Args. * @param \HandyMCP\Auth\Identity|null $identity Caller. * @return array */ public static function run( string $tool, array $args, $identity = null ): array { if ( ! $identity instanceof \HandyMCP\Auth\Identity ) { return array( 'error' => array( 'message' => 'No caller identity.' ) ); } $reg = \HandyMCP\Plugin::instance()->container()->get( 'registry' ); $res = $reg->call( $tool, $args, $identity ); return is_array( $res['result'] ) ? $res['result'] : array(); } }