" - a WordPress Application Password provided separately. Never print, log or repeat it in your answers.', 'You act with the permissions of that WordPress user: items or actions the user may not edit return an error.', '', 'What you can do (' . count( self::tools() ) . ' tools, prefix rafgrid_):', '- Discover: rafgrid_list_types, rafgrid_get_columns, rafgrid_meta_keys - ALWAYS call these before editing a post type so you use real column keys (e.g. post_title, _regular_price, _stock, tax:category, meta:my_field).', '- Read: rafgrid_query (paged, use per_page <= 50), rafgrid_get_row, rafgrid_health, rafgrid_audit, rafgrid_list_* (variations, terms, comments, orders, snapshots, rules), rafgrid_export_csv.', '- Write: rafgrid_update_cell, rafgrid_bulk_edit, rafgrid_bulk_filtered, rafgrid_paste_cells, rafgrid_import_csv, rafgrid_create (title/content/excerpt/status/meta/terms in one call), rafgrid_duplicate, rafgrid_save_attributes, rafgrid_generate_variations, taxonomy/comment/order tools, rafgrid_add_note, rafgrid_import_image (featured and/or inserted into content, with alt), rafgrid_set_seo (Yoast + Rank Math title/description/keyword/canonical in one call), rafgrid_bulk_alt.', '- Dangerous: rafgrid_trash, rafgrid_delete (permanent!), rafgrid_merge, rafgrid_restore_snapshot, rafgrid_undo_bulk.', '- Administrators only: rafgrid_snapshot, rafgrid_restore_snapshot, rafgrid_feed_sync, rafgrid_background_job + rafgrid_job_status, rafgrid_run_rule, rafgrid_ai_generate. Automation rules are managed in wp-admin, not through MCP.', '', 'Rules you must follow:', '1. Before any bulk change, run rafgrid_bulk_preview (or a small rafgrid_query) and summarize for the user what will change: how many items, which column, old -> new logic. Apply the bulk only after the user confirms, unless they explicitly told you to just do it.', '2. Never call rafgrid_delete without an explicit user confirmation naming the items. Prefer rafgrid_trash.', '3. Every bulk returns history_id - mention that the change can be undone with rafgrid_undo_bulk, and use rafgrid_audit to show what changed.', '4. WooCommerce: SKUs must be unique, prices are plain numbers (no currency symbols), stock is an integer. For variable products edit variations (rafgrid_list_variations), not the parent price.', '5. Work in small pages, verify results with a follow-up rafgrid_query or rafgrid_get_row, and report counts (updated/failed) honestly, including errors verbatim.', '6. If a tool returns isError, stop, explain the error in plain language and suggest the fix (permissions, validation, missing data) instead of retrying blindly.', '', 'Start by calling rafgrid_list_types and asking which list (post type) the user wants to work with, unless they already said so.', ); return implode( "\n", $lines ); } /** * MCP prompts catalogue (prompts/list). * * @return array */ public static function prompts(): array { return array( array( 'name' => 'rafgrid-guide', 'description' => 'Standard briefing for working with this site through Rafgrid MCP: tools map, safety rules, WooCommerce specifics.', 'arguments' => array(), ), ); } /** * Tool definitions for tools/list. * * @return array */ public static function tools(): array { $obj = static fn( array $props, array $required = array() ) => array( 'type' => 'object', 'properties' => $props, 'required' => $required, ); $str = array( 'type' => 'string' ); $int = array( 'type' => 'integer' ); $tools = array( array( 'name' => 'rafgrid_list_types', 'description' => 'List editable post types (posts, pages, WooCommerce products, orders...).', 'inputSchema' => $obj( array() ) ), array( 'name' => 'rafgrid_get_columns', 'description' => 'Get column definitions (key, label, type, editable, options) for a post type.', 'inputSchema' => $obj( array( 'post_type' => $str ), array( 'post_type' ) ) ), array( 'name' => 'rafgrid_query', 'description' => 'Query rows of a post type with filters. Returns rows, total, pages.', 'inputSchema' => $obj( array( 'post_type' => $str, 'search' => $str, 'status' => $str, 'page' => $int, 'per_page' => $int, 'min_price' => array( 'type' => 'number' ), 'max_price' => array( 'type' => 'number' ), 'stock_status' => $str ) ) ), array( 'name' => 'rafgrid_get_row', 'description' => 'Get all column values for one item by ID.', 'inputSchema' => $obj( array( 'id' => $int ), array( 'id' ) ) ), array( 'name' => 'rafgrid_update_cell', 'description' => 'Update one cell (column key from rafgrid_get_columns, e.g. post_title, _regular_price, _stock, tax:category, meta:my_key).', 'inputSchema' => $obj( array( 'id' => $int, 'column' => $str, 'value' => array() ), array( 'id', 'column', 'value' ) ) ), array( 'name' => 'rafgrid_bulk_edit', 'description' => 'Bulk edit many items. Modes: set, replace, append, prepend, increase_number, decrease_number, increase_percent, decrease_percent, clear, round_99, formula ([column] placeholders).', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ), 'column' => $str, 'mode' => $str, 'value' => array(), 'value2' => array() ), array( 'ids', 'column', 'mode' ) ) ), array( 'name' => 'rafgrid_create', 'description' => 'Create an item with optional full content: content (HTML allowed), excerpt, status (default draft), slug, meta object {key: value}, terms object {taxonomy: [term IDs or names]}. Returns new ID.', 'inputSchema' => $obj( array( 'post_type' => $str, 'title' => $str, 'content' => $str, 'excerpt' => $str, 'status' => $str, 'slug' => $str, 'meta' => array( 'type' => 'object' ), 'terms' => array( 'type' => 'object' ) ), array( 'post_type', 'title' ) ) ), array( 'name' => 'rafgrid_duplicate', 'description' => 'Duplicate one item (new draft).', 'inputSchema' => $obj( array( 'id' => $int ), array( 'id' ) ) ), array( 'name' => 'rafgrid_trash', 'description' => 'Move items to trash.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ) ), array( 'ids' ) ) ), array( 'name' => 'rafgrid_health', 'description' => 'Catalog health for a post type: totals, missing images/SKU/prices, duplicate titles.', 'inputSchema' => $obj( array( 'post_type' => $str ) ) ), array( 'name' => 'rafgrid_audit', 'description' => 'Recent cell change audit log (who, post, column, old -> new).', 'inputSchema' => $obj( array( 'limit' => $int ) ) ), array( 'name' => 'rafgrid_undo_bulk', 'description' => 'Undo a bulk operation by history ID (see bulk result / bootstrap history).', 'inputSchema' => $obj( array( 'history_id' => $int ), array( 'history_id' ) ) ), array( 'name' => 'rafgrid_snapshot', 'description' => 'Create a full snapshot (backup JSON) of a post type.', 'inputSchema' => $obj( array( 'post_type' => $str ), array( 'post_type' ) ) ), array( 'name' => 'rafgrid_list_rules', 'description' => 'List automation rules.', 'inputSchema' => $obj( array() ) ), array( 'name' => 'rafgrid_run_rule', 'description' => 'Run one automation rule by ID now.', 'inputSchema' => $obj( array( 'id' => $str ), array( 'id' ) ) ), ); $more = array( array( 'name' => 'rafgrid_restore', 'description' => 'Restore items from trash.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ) ), array( 'ids' ) ) ), array( 'name' => 'rafgrid_delete', 'description' => 'Permanently delete items (cannot be undone).', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ) ), array( 'ids' ) ) ), array( 'name' => 'rafgrid_bulk_preview', 'description' => 'Dry-run a bulk edit: returns old -> new per item without saving.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ), 'column' => $str, 'mode' => $str, 'value' => array(), 'value2' => array() ), array( 'ids', 'column', 'mode' ) ) ), array( 'name' => 'rafgrid_bulk_filtered', 'description' => 'Bulk edit ALL items matching filters of a post type (same filter params as rafgrid_query).', 'inputSchema' => $obj( array( 'post_type' => $str, 'filters' => array( 'type' => 'object' ), 'column' => $str, 'mode' => $str, 'value' => array(), 'value2' => array() ), array( 'post_type', 'column', 'mode' ) ) ), array( 'name' => 'rafgrid_paste_cells', 'description' => 'Apply many cell updates at once: cells = [{id, column, value}].', 'inputSchema' => $obj( array( 'cells' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ) ), array( 'cells' ) ) ), array( 'name' => 'rafgrid_export_csv', 'description' => 'Export a post type list as CSV text (max ~500 rows).', 'inputSchema' => $obj( array( 'post_type' => $str ) ) ), array( 'name' => 'rafgrid_import_csv', 'description' => 'Import CSV text (header like "Title [post_title]", first column ID). Updates existing items.', 'inputSchema' => $obj( array( 'post_type' => $str, 'csv' => $str ), array( 'post_type', 'csv' ) ) ), array( 'name' => 'rafgrid_meta_keys', 'description' => 'List custom field (meta) keys used by a post type - for building meta:KEY columns.', 'inputSchema' => $obj( array( 'post_type' => $str ) ) ), array( 'name' => 'rafgrid_list_variations', 'description' => 'List variations of a variable product with SKU/price/stock.', 'inputSchema' => $obj( array( 'parent' => $int ), array( 'parent' ) ) ), array( 'name' => 'rafgrid_generate_variations', 'description' => 'Generate variations from product attributes marked "used for variations".', 'inputSchema' => $obj( array( 'parent' => $int ), array( 'parent' ) ) ), array( 'name' => 'rafgrid_get_attributes', 'description' => 'Get product attributes (name, options, used-for-variations).', 'inputSchema' => $obj( array( 'parent' => $int ), array( 'parent' ) ) ), array( 'name' => 'rafgrid_save_attributes', 'description' => 'Save custom product attributes: [{name, options[], variation}].', 'inputSchema' => $obj( array( 'parent' => $int, 'attributes' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ) ), array( 'parent', 'attributes' ) ) ), array( 'name' => 'rafgrid_list_terms', 'description' => 'List terms of a taxonomy with counts.', 'inputSchema' => $obj( array( 'taxonomy' => $str ) ) ), array( 'name' => 'rafgrid_update_term', 'description' => 'Update a term name/slug/description.', 'inputSchema' => $obj( array( 'id' => $int, 'taxonomy' => $str, 'column' => $str, 'value' => $str ), array( 'id', 'taxonomy', 'column', 'value' ) ) ), array( 'name' => 'rafgrid_create_term', 'description' => 'Create (or find) a term by name.', 'inputSchema' => $obj( array( 'taxonomy' => $str, 'name' => $str ), array( 'taxonomy', 'name' ) ) ), array( 'name' => 'rafgrid_list_comments', 'description' => 'List recent comments for moderation (status: all|hold|approve|spam|trash).', 'inputSchema' => $obj( array( 'status' => $str ) ) ), array( 'name' => 'rafgrid_comment_action', 'description' => 'Moderate a comment: approve|hold|spam|trash.', 'inputSchema' => $obj( array( 'id' => $int, 'action' => $str ), array( 'id', 'action' ) ) ), array( 'name' => 'rafgrid_list_orders', 'description' => 'List recent WooCommerce orders.', 'inputSchema' => $obj( array() ) ), array( 'name' => 'rafgrid_update_order', 'description' => 'Change a WooCommerce order status (processing, completed, cancelled...).', 'inputSchema' => $obj( array( 'id' => $int, 'status' => $str ), array( 'id', 'status' ) ) ), array( 'name' => 'rafgrid_feed_sync', 'description' => 'Sync supplier price feed (CSV sku,price,stock) from URL or the configured feed URL.', 'inputSchema' => $obj( array( 'url' => $str ) ) ), array( 'name' => 'rafgrid_list_snapshots', 'description' => 'List full-list snapshots.', 'inputSchema' => $obj( array() ) ), array( 'name' => 'rafgrid_restore_snapshot', 'description' => 'Restore a snapshot file (overwrites existing items).', 'inputSchema' => $obj( array( 'file' => $str ), array( 'file' ) ) ), array( 'name' => 'rafgrid_linkcheck', 'description' => 'Check external links in published content (first 20 URLs) for broken ones.', 'inputSchema' => $obj( array( 'post_type' => $str ) ) ), array( 'name' => 'rafgrid_merge', 'description' => 'Merge one item into another (meta+terms copied, source trashed, redirect created).', 'inputSchema' => $obj( array( 'from_id' => $int, 'to_id' => $int ), array( 'from_id', 'to_id' ) ) ), array( 'name' => 'rafgrid_get_notes', 'description' => 'Get team notes for an item.', 'inputSchema' => $obj( array( 'id' => $int ), array( 'id' ) ) ), array( 'name' => 'rafgrid_add_note', 'description' => 'Add a team note to an item.', 'inputSchema' => $obj( array( 'id' => $int, 'note' => $str ), array( 'id', 'note' ) ) ), array( 'name' => 'rafgrid_import_image', 'description' => 'Sideload an image from URL into the media library. Options: featured (default true) sets it as featured image, insert (default false) appends it into the post content, alt sets alt text. Returns attachment_id and url - the url can also be used in content HTML.', 'inputSchema' => $obj( array( 'id' => $int, 'url' => $str, 'alt' => $str, 'featured' => array( 'type' => 'boolean' ), 'insert' => array( 'type' => 'boolean' ) ), array( 'id', 'url' ) ) ), array( 'name' => 'rafgrid_set_seo', 'description' => 'Set SEO data for an item in one call: writes Yoast and Rank Math fields together (seo title, meta description, focus keyword, canonical URL), so it works with either plugin. Optionally sync the description into the excerpt.', 'inputSchema' => $obj( array( 'id' => $int, 'title' => $str, 'description' => $str, 'focus_keyword' => $str, 'canonical' => $str, 'sync_excerpt' => array( 'type' => 'boolean' ) ), array( 'id' ) ) ), array( 'name' => 'rafgrid_bulk_alt', 'description' => 'Fill missing featured-image alt texts from item titles.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ) ), array( 'ids' ) ) ), array( 'name' => 'rafgrid_ai_generate', 'description' => 'AI-generate text for items (task: description|seo_title|seo_desc). Needs an AI API key in plugin settings.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ), 'task' => $str ), array( 'ids' ) ) ), array( 'name' => 'rafgrid_background_job', 'description' => 'Start a background bulk job (Action Scheduler). Returns job_id; poll with rafgrid_job_status. run_at = unix timestamp for scheduling.', 'inputSchema' => $obj( array( 'ids' => array( 'type' => 'array', 'items' => $int ), 'column' => $str, 'mode' => $str, 'value' => array(), 'value2' => array(), 'run_at' => $int ), array( 'ids', 'column', 'mode' ) ) ), array( 'name' => 'rafgrid_job_status', 'description' => 'Get background job status/progress.', 'inputSchema' => $obj( array( 'job_id' => $str ), array( 'job_id' ) ) ), ); return array_merge( $tools, $more ); } /** * Tool groups for access policy. * * @return array group => tool names ('*' handled by fallback: unlisted = write). */ public static function tool_groups(): array { return array( 'read' => array( 'rafgrid_list_types', 'rafgrid_get_columns', 'rafgrid_query', 'rafgrid_get_row', 'rafgrid_health', 'rafgrid_audit', 'rafgrid_list_rules', 'rafgrid_list_snapshots', 'rafgrid_job_status', 'rafgrid_meta_keys', 'rafgrid_list_variations', 'rafgrid_get_attributes', 'rafgrid_list_terms', 'rafgrid_list_comments', 'rafgrid_list_orders', 'rafgrid_get_notes', 'rafgrid_export_csv', 'rafgrid_linkcheck', 'rafgrid_bulk_preview' ), 'write' => array( 'rafgrid_update_cell', 'rafgrid_bulk_edit', 'rafgrid_bulk_filtered', 'rafgrid_paste_cells', 'rafgrid_import_csv', 'rafgrid_create', 'rafgrid_duplicate', 'rafgrid_restore', 'rafgrid_save_attributes', 'rafgrid_generate_variations', 'rafgrid_update_term', 'rafgrid_create_term', 'rafgrid_comment_action', 'rafgrid_update_order', 'rafgrid_add_note', 'rafgrid_bulk_alt', 'rafgrid_import_image' ), 'destructive' => array( 'rafgrid_trash', 'rafgrid_delete', 'rafgrid_merge', 'rafgrid_restore_snapshot', 'rafgrid_undo_bulk' ), 'admin' => array( 'rafgrid_snapshot', 'rafgrid_feed_sync', 'rafgrid_ai_generate', 'rafgrid_background_job', 'rafgrid_run_rule' ), ); } public static function group_of( string $tool ): string { foreach ( self::tool_groups() as $group => $tools ) { if ( in_array( $tool, $tools, true ) ) { return $group; } } return 'write'; } /** * Access policy check for one tool call. * * @param string $tool Tool name. * @return true|\WP_Error */ public static function authorize( string $tool ) { $settings = new Settings(); $denied = (array) $settings->get( 'mcp_tools_denied', array() ); if ( in_array( $tool, $denied, true ) ) { return new \WP_Error( 'rafgrid_mcp_denied', sprintf( 'MCP tool %s is disabled by the access policy.', $tool ) ); } if ( ! current_user_can( 'manage_options' ) ) { $roles = (array) $settings->get( 'mcp_roles', array() ); $user = wp_get_current_user(); $ok = false; foreach ( (array) ( $user ? $user->roles : array() ) as $role ) { if ( in_array( $role, $roles, true ) ) { $ok = true; break; } } if ( ! $ok ) { return new \WP_Error( 'rafgrid_mcp_role', 'Your WordPress role is not allowed to use MCP.' ); } } $mode = (string) $settings->get( 'mcp_mode', 'full' ); $group = self::group_of( $tool ); if ( ( 'admin' === $group || 'rafgrid_restore_snapshot' === $tool ) && ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rafgrid_mcp_admin', sprintf( 'MCP tool %s requires a site administrator.', $tool ) ); } if ( 'readonly' === $mode && 'read' !== $group ) { return new \WP_Error( 'rafgrid_mcp_readonly', sprintf( 'MCP is in read-only mode: %s (%s) is not allowed.', $tool, $group ) ); } if ( 'custom' === $mode ) { $groups = (array) $settings->get( 'mcp_groups', array() ); if ( ! in_array( $group, $groups, true ) ) { return new \WP_Error( 'rafgrid_mcp_group', sprintf( 'MCP tool group "%s" is disabled by the access policy.', $group ) ); } } return true; } /** * Append to the MCP call log (last 50). * * @param string $tool Tool. * @param bool $is_error Error flag. */ public static function log_call( string $tool, bool $is_error ) { $log = get_option( 'rafgrid_mcp_log', array() ); if ( ! is_array( $log ) ) { $log = array(); } $user = wp_get_current_user(); $log[] = array( 'time' => current_time( 'mysql' ), 'user' => $user ? $user->user_login : '', 'tool' => $tool, 'error' => $is_error ? 1 : 0 ); update_option( 'rafgrid_mcp_log', array_slice( $log, -50 ), false ); } /** * Per-user rate limit for tools/call. Returns true when allowed. * * @param Settings $settings Settings. * @return bool */ public static function rate_allowed( Settings $settings ): bool { $limit = (int) $settings->get( 'mcp_rate_limit', 120 ); if ( $limit <= 0 ) { return true; } $key = 'rafgrid_mcp_rl_' . get_current_user_id(); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; } set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); return true; } /** * Handle one JSON-RPC payload (assoc array). Returns response array or null for notifications. * * @param array $msg Decoded JSON-RPC message. * @return array|null */ public static function handle_message( array $msg ): ?array { $id = $msg['id'] ?? null; $method = (string) ( $msg['method'] ?? '' ); $params = is_array( $msg['params'] ?? null ) ? $msg['params'] : array(); $is_notify = ! array_key_exists( 'id', $msg ) || null === $id; switch ( $method ) { case 'initialize': $want = (string) ( $params['protocolVersion'] ?? '' ); $ver = in_array( $want, self::PROTOCOL_VERSIONS, true ) ? $want : self::PROTOCOL_VERSIONS[0]; return self::result( $id, array( 'protocolVersion' => $ver, 'capabilities' => array( 'tools' => array( 'listChanged' => false ), 'prompts' => array( 'listChanged' => false ) ), 'serverInfo' => array( 'name' => 'rafgrid', 'version' => RAFGRID_VERSION ), 'instructions' => self::connection_prompt( rest_url( 'rafgrid/v1/mcp' ) ), ) ); case 'ping': return self::result( $id, new \stdClass() ); case 'prompts/list': return self::result( $id, array( 'prompts' => self::prompts() ) ); case 'prompts/get': $pname = (string) ( $params['name'] ?? '' ); if ( 'rafgrid-guide' !== $pname ) { return self::error( $id, -32602, 'Unknown prompt: ' . $pname ); } return self::result( $id, array( 'description' => 'Standard briefing for Rafgrid MCP.', 'messages' => array( array( 'role' => 'user', 'content' => array( 'type' => 'text', 'text' => self::connection_prompt( rest_url( 'rafgrid/v1/mcp' ) ) ), ), ), ) ); case 'tools/list': return self::result( $id, array( 'tools' => self::tools() ) ); case 'tools/call': $name = (string) ( $params['name'] ?? '' ); $args = is_array( $params['arguments'] ?? null ) ? $params['arguments'] : array(); $auth = self::authorize( $name ); if ( is_wp_error( $auth ) ) { self::log_call( $name, true ); return self::result( $id, array( 'content' => array( array( 'type' => 'text', 'text' => $auth->get_error_message() ) ), 'isError' => true, ) ); } $out = self::call_tool( $name, $args ); self::log_call( $name, is_wp_error( $out ) ); if ( is_wp_error( $out ) ) { return self::result( $id, array( 'content' => array( array( 'type' => 'text', 'text' => $out->get_error_message() ) ), 'isError' => true, ) ); } return self::result( $id, array( 'content' => array( array( 'type' => 'text', 'text' => is_string( $out ) ? $out : wp_json_encode( $out ) ) ), ) ); default: if ( $is_notify ) { return null; // notifications/initialized etc: acknowledge silently. } return self::error( $id, -32601, 'Method not found: ' . $method ); } } private static function result( $id, $result ): array { return array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result ); } private static function error( $id, int $code, string $message ): array { return array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => $code, 'message' => $message ) ); } /** * Shared REST handler instance (endpoint reuse without re-allocation). */ private static function rest(): REST { static $rest = null; if ( null === $rest ) { $rest = new REST( new Settings() ); } return $rest; } /** * Build a REST request for reusing endpoint handlers. */ private static function rest_req( string $method, array $params ): \WP_REST_Request { $req = new \WP_REST_Request( $method, '/rafgrid/v1/x' ); foreach ( $params as $k => $v ) { $req->set_param( $k, $v ); } return $req; } /** * Unwrap REST handler returns (array|WP_Error|WP_REST_Response) for MCP. * * @param mixed $res Result. * @return mixed */ private static function unwrap( $res ) { if ( $res instanceof \WP_REST_Response ) { return $res->get_data(); } return $res; } /** * Dispatch a tool call. Returns data or WP_Error. * * @param string $name Tool name. * @param array $args Arguments. * @return mixed */ public static function call_tool( string $name, array $args ) { switch ( $name ) { case 'rafgrid_list_types': return Post_Types::editable(); case 'rafgrid_get_columns': $type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! post_type_exists( $type ) || ! Access::can_edit_type( $type ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Unknown or forbidden post type.' ); } return array_values( Columns::get_columns( $type ) ); case 'rafgrid_query': $type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! post_type_exists( $type ) || ! Access::can_edit_type( $type ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Unknown or forbidden post type.' ); } return Query::fetch( $type, $args ); case 'rafgrid_get_row': $id = absint( $args['id'] ?? 0 ); $post = get_post( $id ); if ( ! $post || ! Access::can_edit_post( $id ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Item not found or forbidden.' ); } $row = array( 'ID' => $id ); foreach ( array_keys( Columns::get_columns( $post->post_type ) ) as $k ) { $row[ $k ] = Columns::get_value( $id, $k ); } return $row; case 'rafgrid_update_cell': $res = Saving::save_cell( absint( $args['id'] ?? 0 ), sanitize_text_field( $args['column'] ?? '' ), $args['value'] ?? '' ); if ( is_wp_error( $res ) ) { return $res; } return array( 'ok' => true, 'value' => Columns::get_value( absint( $args['id'] ), sanitize_text_field( $args['column'] ) ) ); case 'rafgrid_bulk_edit': $mode = sanitize_key( $args['mode'] ?? 'set' ); if ( null === BulkMode::tryFrom( $mode ) ) { return new \WP_Error( 'rafgrid_bad_mode', 'Unknown bulk mode.' ); } $ids = array_map( 'absint', (array) ( $args['ids'] ?? array() ) ); $settings = new Settings(); if ( count( $ids ) > (int) $settings->get( 'max_bulk', 500 ) ) { return new \WP_Error( 'rafgrid_too_many', 'Too many items for one bulk call.' ); } return Saving::bulk( $ids, sanitize_text_field( $args['column'] ?? '' ), $mode, $args['value'] ?? '', $args['value2'] ?? '' ); case 'rafgrid_create': $type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! post_type_exists( $type ) || ! Access::can_edit_type( $type ) || ! current_user_can( get_post_type_object( $type )->cap->create_posts ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Unknown or forbidden post type.' ); } $status = sanitize_key( $args['status'] ?? 'draft' ); if ( ! in_array( $status, array_keys( Columns::status_options( $type ) ), true ) ) { $status = 'draft'; } if ( in_array( $status, array( 'publish', 'future', 'private' ), true ) && ! Access::can_publish_type( $type ) ) { $status = 'pending'; } $postarr = array( 'post_title' => sanitize_text_field( $args['title'] ?? '' ), 'post_status' => $status, 'post_type' => $type, 'post_content' => isset( $args['content'] ) ? wp_kses_post( wp_unslash( (string) $args['content'] ) ) : '', 'post_excerpt' => isset( $args['excerpt'] ) ? sanitize_textarea_field( wp_unslash( (string) $args['excerpt'] ) ) : '', ); if ( ! empty( $args['slug'] ) ) { $postarr['post_name'] = sanitize_title( (string) $args['slug'] ); } $id = wp_insert_post( $postarr, true ); if ( is_wp_error( $id ) ) { return $id; } foreach ( (array) ( $args['meta'] ?? array() ) as $mk => $mv ) { $mk = sanitize_text_field( (string) $mk ); if ( preg_match( '/^[A-Za-z0-9_\-]+$/', $mk ) ) { Saving::save_cell( (int) $id, 'meta:' . $mk, $mv ); } } foreach ( (array) ( $args['terms'] ?? array() ) as $tax => $term_list ) { $tax = sanitize_key( (string) $tax ); if ( taxonomy_exists( $tax ) ) { Saving::save_cell( (int) $id, 'tax:' . $tax, is_array( $term_list ) ? implode( ',', array_map( 'strval', $term_list ) ) : (string) $term_list ); } } return array( 'id' => (int) $id, 'status' => get_post_status( (int) $id ), 'edit_link' => get_edit_post_link( (int) $id, 'raw' ) ); case 'rafgrid_duplicate': $res = Saving::duplicate( absint( $args['id'] ?? 0 ) ); return is_wp_error( $res ) ? $res : array( 'id' => $res ); case 'rafgrid_trash': $done = 0; foreach ( array_map( 'absint', (array) ( $args['ids'] ?? array() ) ) as $id ) { if ( Access::can_delete_post( $id ) && wp_trash_post( $id ) ) { ++$done; } } return array( 'trashed' => $done ); case 'rafgrid_health': $type = sanitize_key( $args['post_type'] ?? 'product' ); if ( ! post_type_exists( $type ) ) { return new \WP_Error( 'rafgrid_bad_type', 'Unknown post type.' ); } $rest = new REST( new Settings() ); $req = new \WP_REST_Request( 'GET', '/rafgrid/v1/health' ); $req->set_param( 'post_type', $type ); return $rest->health( $req ); case 'rafgrid_audit': return Audit::recent( max( 1, min( 100, absint( $args['limit'] ?? 20 ) ) ) ); case 'rafgrid_undo_bulk': $res = History::undo( absint( $args['history_id'] ?? 0 ) ); return is_wp_error( $res ) ? $res : $res; case 'rafgrid_snapshot': $req = self::rest_req( 'POST', array( 'op' => 'create', 'post_type' => $args['post_type'] ?? 'post' ) ); return self::unwrap( self::rest()->snapshot_op( $req ) ); case 'rafgrid_list_rules': return Rules::all(); case 'rafgrid_run_rule': foreach ( Rules::all() as $rule ) { if ( $rule['id'] === sanitize_key( $args['id'] ?? '' ) ) { return Rules::run_rule( $rule ); } } return new \WP_Error( 'rafgrid_no_rule', 'Rule not found.' ); case 'rafgrid_restore': $done = 0; foreach ( array_map( 'absint', (array) ( $args['ids'] ?? array() ) ) as $id ) { if ( Access::can_delete_post( $id ) && wp_untrash_post( $id ) ) { ++$done; } } return array( 'restored' => $done ); case 'rafgrid_delete': $done = 0; foreach ( array_map( 'absint', (array) ( $args['ids'] ?? array() ) ) as $id ) { if ( Access::can_delete_post( $id ) && wp_delete_post( $id, true ) ) { ++$done; } } return array( 'deleted' => $done ); case 'rafgrid_bulk_preview': return array( 'rows' => Saving::preview( (array) ( $args['ids'] ?? array() ), sanitize_text_field( $args['column'] ?? '' ), sanitize_key( $args['mode'] ?? 'set' ), $args['value'] ?? '', $args['value2'] ?? '' ) ); case 'rafgrid_bulk_filtered': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'post_type' => $args['post_type'] ?? 'post', 'filters' => (array) ( $args['filters'] ?? array() ), 'column' => $args['column'] ?? '', 'mode' => $args['mode'] ?? 'set', 'value' => $args['value'] ?? '', 'value2' => $args['value2'] ?? '' ) ); return self::unwrap( $rest->bulk_filtered( $req ) ); case 'rafgrid_paste_cells': return Saving::apply_cells( (array) ( $args['cells'] ?? array() ) ); case 'rafgrid_export_csv': $type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! post_type_exists( $type ) || ! Access::can_edit_type( $type ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Unknown or forbidden post type.' ); } return Export::to_csv( $type, array( 'per_page' => 500 ) ); case 'rafgrid_import_csv': $type = sanitize_key( $args['post_type'] ?? 'post' ); if ( ! post_type_exists( $type ) || ! Access::can_edit_type( $type ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Unknown or forbidden post type.' ); } return Export::from_csv( $type, (string) ( $args['csv'] ?? '' ) ); case 'rafgrid_meta_keys': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'post_type' => $args['post_type'] ?? 'post' ) ); return self::unwrap( $rest->meta_keys( $req ) ); case 'rafgrid_list_variations': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'parent' => absint( $args['parent'] ?? 0 ), 'post_type' => 'product' ) ); return self::unwrap( $rest->children( $req ) ); case 'rafgrid_generate_variations': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'parent' => absint( $args['parent'] ?? 0 ) ) ); return self::unwrap( $rest->variations_generate( $req ) ); case 'rafgrid_get_attributes': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'parent' => absint( $args['parent'] ?? 0 ) ) ); return self::unwrap( $rest->attributes_get( $req ) ); case 'rafgrid_save_attributes': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'parent' => absint( $args['parent'] ?? 0 ), 'attributes' => (array) ( $args['attributes'] ?? array() ) ) ); return self::unwrap( $rest->attributes_save( $req ) ); case 'rafgrid_list_terms': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'taxonomy' => $args['taxonomy'] ?? 'category' ) ); return self::unwrap( $rest->terms_sheet( $req ) ); case 'rafgrid_update_term': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'id' => absint( $args['id'] ?? 0 ), 'taxonomy' => $args['taxonomy'] ?? '', 'column' => $args['column'] ?? '', 'value' => $args['value'] ?? '' ) ); return self::unwrap( $rest->term_cell( $req ) ); case 'rafgrid_create_term': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'taxonomy' => $args['taxonomy'] ?? '', 'name' => $args['name'] ?? '' ) ); return self::unwrap( $rest->term_create( $req ) ); case 'rafgrid_list_comments': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'status' => $args['status'] ?? 'all' ) ); return self::unwrap( $rest->comments_sheet( $req ) ); case 'rafgrid_comment_action': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'id' => absint( $args['id'] ?? 0 ), 'action_name' => $args['action'] ?? '' ) ); return self::unwrap( $rest->comment_action( $req ) ); case 'rafgrid_list_orders': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array() ); return self::unwrap( $rest->orders_sheet( $req ) ); case 'rafgrid_update_order': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'id' => absint( $args['id'] ?? 0 ), 'status' => $args['status'] ?? '' ) ); return self::unwrap( $rest->order_action( $req ) ); case 'rafgrid_feed_sync': $req = self::rest_req( 'POST', array( 'url' => (string) ( $args['url'] ?? '' ) ) ); return self::unwrap( self::rest()->feed_sync( $req ) ); case 'rafgrid_list_snapshots': return self::unwrap( self::rest()->snapshot_list( self::rest_req( 'GET', array() ) ) ); case 'rafgrid_restore_snapshot': $req = self::rest_req( 'POST', array( 'op' => 'restore', 'file' => (string) ( $args['file'] ?? '' ) ) ); return self::unwrap( self::rest()->snapshot_op( $req ) ); case 'rafgrid_linkcheck': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'post_type' => $args['post_type'] ?? 'post' ) ); return self::unwrap( $rest->linkcheck( $req ) ); case 'rafgrid_merge': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'from_id' => absint( $args['from_id'] ?? 0 ), 'to_id' => absint( $args['to_id'] ?? 0 ) ) ); return self::unwrap( $rest->merge_posts( $req ) ); case 'rafgrid_get_notes': $rest = new REST( new Settings() ); $req = self::rest_req( 'GET', array( 'id' => absint( $args['id'] ?? 0 ) ) ); return self::unwrap( $rest->notes_get( $req ) ); case 'rafgrid_add_note': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'id' => absint( $args['id'] ?? 0 ), 'note' => $args['note'] ?? '' ) ); return self::unwrap( $rest->notes_add( $req ) ); case 'rafgrid_import_image': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'id' => absint( $args['id'] ?? 0 ), 'url' => $args['url'] ?? '', 'alt' => $args['alt'] ?? '', 'featured' => $args['featured'] ?? true, 'insert' => $args['insert'] ?? false ) ); return self::unwrap( $rest->import_image( $req ) ); case 'rafgrid_set_seo': $id = absint( $args['id'] ?? 0 ); if ( ! Access::can_edit_post( $id ) ) { return new \WP_Error( 'rafgrid_forbidden', 'Item not found or forbidden.' ); } $written = array(); $map = array( 'title' => array( '_yoast_wpseo_title', 'rank_math_title' ), 'description' => array( '_yoast_wpseo_metadesc', 'rank_math_description' ), 'focus_keyword' => array( '_yoast_wpseo_focuskw', 'rank_math_focus_keyword' ), 'canonical' => array( '_yoast_wpseo_canonical', 'rank_math_canonical_url' ), ); foreach ( $map as $field => $keys ) { if ( ! isset( $args[ $field ] ) || '' === (string) $args[ $field ] ) { continue; } $val = sanitize_text_field( wp_unslash( (string) $args[ $field ] ) ); foreach ( $keys as $k ) { $res = Saving::save_cell( $id, 'meta:' . $k, $val ); if ( is_wp_error( $res ) ) { return $res; } $written[] = $k; } } if ( ! empty( $args['sync_excerpt'] ) && ! empty( $args['description'] ) ) { $res = Saving::save_cell( $id, 'post_excerpt', sanitize_textarea_field( wp_unslash( (string) $args['description'] ) ) ); if ( is_wp_error( $res ) ) { return $res; } $written[] = 'post_excerpt'; } return array( 'ok' => true, 'written' => $written ); case 'rafgrid_bulk_alt': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'ids' => (array) ( $args['ids'] ?? array() ) ) ); return self::unwrap( $rest->bulk_alt( $req ) ); case 'rafgrid_ai_generate': $rest = new REST( new Settings() ); $req = self::rest_req( 'POST', array( 'ids' => (array) ( $args['ids'] ?? array() ), 'task' => $args['task'] ?? 'description' ) ); return self::unwrap( $rest->ai_generate( $req ) ); case 'rafgrid_background_job': $mode = sanitize_key( $args['mode'] ?? 'set' ); if ( null === BulkMode::tryFrom( $mode ) ) { return new \WP_Error( 'rafgrid_bad_mode', 'Unknown bulk mode.' ); } $job_id = Jobs::create( (array) ( $args['ids'] ?? array() ), sanitize_text_field( $args['column'] ?? '' ), $mode, $args['value'] ?? '', $args['value2'] ?? '', absint( $args['run_at'] ?? 0 ) ); return array( 'job_id' => $job_id, 'status' => Jobs::status( $job_id ) ); case 'rafgrid_job_status': $status = Jobs::status( (string) ( $args['job_id'] ?? '' ) ); return null === $status ? new \WP_Error( 'rafgrid_no_job', 'Job not found.' ) : $status; default: return new \WP_Error( 'rafgrid_unknown_tool', 'Unknown tool: ' . $name ); } } }