settings = $settings; add_action( 'rest_api_init', array( $this, 'routes' ) ); } /** * Register routes. */ public function routes() { register_rest_route( 'rafgrid/v1', '/bootstrap', array( 'methods' => 'GET', 'callback' => array( $this, 'bootstrap' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/rows', array( 'methods' => 'GET', 'callback' => array( $this, 'rows' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => array_merge( $this->type_arg(), array( 'page' => array( 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint' ), 'per_page' => array( 'type' => 'integer', 'default' => 50, 'sanitize_callback' => 'absint' ), 'search' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'status' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ), ) ), ) ); register_rest_route( 'rafgrid/v1', '/cell', array( 'methods' => 'POST', 'callback' => array( $this, 'save_cell' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/bulk', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/row-action', array( 'methods' => 'POST', 'callback' => array( $this, 'row_action' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/create', array( 'methods' => 'POST', 'callback' => array( $this, 'create' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/columns', array( 'methods' => 'GET', 'callback' => array( $this, 'columns' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg(), ) ); register_rest_route( 'rafgrid/v1', '/prefs', array( array( 'methods' => 'GET', 'callback' => array( $this, 'prefs_get' ), 'permission_callback' => array( $this, 'can_use' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'prefs_save' ), 'permission_callback' => array( $this, 'can_use' ), ), ) ); register_rest_route( 'rafgrid/v1', '/views', array( array( 'methods' => 'GET', 'callback' => array( $this, 'views_get' ), 'permission_callback' => array( $this, 'can_use' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'views_save' ), 'permission_callback' => array( $this, 'can_use' ), ), ) ); register_rest_route( 'rafgrid/v1', '/presets', array( array( 'methods' => 'GET', 'callback' => array( $this, 'presets_get' ), 'permission_callback' => array( $this, 'can_use' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'presets_save' ), 'permission_callback' => array( $this, 'can_use' ), ), ) ); register_rest_route( 'rafgrid/v1', '/undo', array( 'methods' => 'POST', 'callback' => array( $this, 'undo' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/export', array( 'methods' => 'GET', 'callback' => array( $this, 'export' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg(), ) ); register_rest_route( 'rafgrid/v1', '/import', array( 'methods' => 'POST', 'callback' => array( $this, 'import' ), 'permission_callback' => array( $this, 'can_use' ), ) ); register_rest_route( 'rafgrid/v1', '/meta-keys', array( 'methods' => 'GET', 'callback' => array( $this, 'meta_keys' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg(), ) ); register_rest_route( 'rafgrid/v1', '/paste', array( 'methods' => 'POST', 'callback' => array( $this, 'paste' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/bulk-preview', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk_preview' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/bulk-filtered', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk_filtered' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/bulk-job', array( array( 'methods' => 'POST', 'callback' => array( $this, 'bulk_job_create' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'GET', 'callback' => array( $this, 'bulk_job_status' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/children', array( 'methods' => 'GET', 'callback' => array( $this, 'children' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg() ) ); register_rest_route( 'rafgrid/v1', '/audit', array( array( 'methods' => 'GET', 'callback' => array( $this, 'audit_list' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'audit_rollback' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/terms-sheet', array( 'methods' => 'GET', 'callback' => array( $this, 'terms_sheet' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/term-cell', array( 'methods' => 'POST', 'callback' => array( $this, 'term_cell' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/term-create', array( 'methods' => 'POST', 'callback' => array( $this, 'term_create' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/comments-sheet', array( array( 'methods' => 'GET', 'callback' => array( $this, 'comments_sheet' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'comment_action' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/lock', array( 'methods' => 'POST', 'callback' => array( $this, 'lock_row' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/feed-sync', array( 'methods' => 'POST', 'callback' => array( $this, 'feed_sync' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/snapshot', array( array( 'methods' => 'GET', 'callback' => array( $this, 'snapshot_list' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'snapshot_op' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/ai', array( 'methods' => 'POST', 'callback' => array( $this, 'ai_generate' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/health', array( 'methods' => 'GET', 'callback' => array( $this, 'health' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg() ) ); register_rest_route( 'rafgrid/v1', '/users-sheet', array( array( 'methods' => 'GET', 'callback' => array( $this, 'users_sheet' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'user_cell' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/orders-sheet', array( array( 'methods' => 'GET', 'callback' => array( $this, 'orders_sheet' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'order_action' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/variations-generate', array( 'methods' => 'POST', 'callback' => array( $this, 'variations_generate' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/import-image', array( 'methods' => 'POST', 'callback' => array( $this, 'import_image' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/bulk-alt', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk_alt' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/rules', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rules_list' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'rules_save' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/presence', array( 'methods' => 'POST', 'callback' => array( $this, 'presence' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/notes', array( array( 'methods' => 'GET', 'callback' => array( $this, 'notes_get' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'notes_add' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/merge', array( 'methods' => 'POST', 'callback' => array( $this, 'merge_posts' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/config', array( array( 'methods' => 'GET', 'callback' => array( $this, 'config_export' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'config_import' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/linkcheck', array( 'methods' => 'GET', 'callback' => array( $this, 'linkcheck' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => $this->type_arg() ) ); register_rest_route( 'rafgrid/v1', '/attributes', array( array( 'methods' => 'GET', 'callback' => array( $this, 'attributes_get' ), 'permission_callback' => array( $this, 'can_use' ) ), array( 'methods' => 'POST', 'callback' => array( $this, 'attributes_save' ), 'permission_callback' => array( $this, 'can_use' ) ), ) ); register_rest_route( 'rafgrid/v1', '/mcp', array( 'methods' => 'POST', 'callback' => array( $this, 'mcp_endpoint' ), 'permission_callback' => array( $this, 'can_use' ) ) ); register_rest_route( 'rafgrid/v1', '/terms', array( 'methods' => 'GET', 'callback' => array( $this, 'terms' ), 'permission_callback' => array( $this, 'can_use' ), 'args' => array( 'taxonomy' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ), 'search' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), ), ) ); } /** * Shared REST argument schema for the post_type parameter. * * @return array */ private function type_arg() { return array( 'post_type' => array( 'type' => 'string', 'default' => 'post', 'sanitize_callback' => 'sanitize_key', ), ); } /** * Permission. * * @return bool */ public function can_use() { return Access::can_use( $this->settings ); } /** * Check type access or return error. * * @param string $type Post type. * @return true|\WP_Error */ private function check_type( $type ) { $type = sanitize_key( $type ); if ( ! post_type_exists( $type ) ) { return new \WP_Error( 'rafgrid_bad_type', __( 'Unknown post type.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( ! Access::can_edit_type( $type ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'You cannot edit this post type.', 'rafgrid' ), array( 'status' => 403 ) ); } return true; } /** * Paste cells from Excel/TSV. */ public function paste( $req ) { $cells = $req->get_param( 'cells' ); if ( ! is_array( $cells ) || empty( $cells ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'No cells.', 'rafgrid' ), array( 'status' => 400 ) ); } return Saving::apply_cells( $cells ); } public function bulk_preview( $req ) { $ids = $req->get_param( 'ids' ); if ( ! is_array( $ids ) || empty( $ids ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'No items.', 'rafgrid' ), array( 'status' => 400 ) ); } return array( 'rows' => Saving::preview( $ids, sanitize_text_field( $req->get_param( 'column' ) ), sanitize_key( $req->get_param( 'mode' ) ), $req->get_param( 'value' ), $req->get_param( 'value2' ) ) ); } public function bulk_filtered( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $mode = sanitize_key( $req->get_param( 'mode' ) ); if ( null === BulkMode::tryFrom( $mode ) ) { return new \WP_Error( 'rafgrid_bad_mode', __( 'Unknown bulk mode.', 'rafgrid' ), array( 'status' => 400 ) ); } $params = (array) $req->get_param( 'filters' ); $cap = (int) $this->settings->get( 'max_bulk', 500 ) * 4; $ids = Saving::filtered_ids( $type, $params, $cap ); if ( empty( $ids ) ) { return new \WP_Error( 'rafgrid_too_many_or_none', __( 'Nothing matched, or too many items matched.', 'rafgrid' ), array( 'status' => 400 ) ); } return Saving::bulk( $ids, sanitize_text_field( $req->get_param( 'column' ) ), $mode, $req->get_param( 'value' ), $req->get_param( 'value2' ) ); } public function bulk_job_create( $req ) { if ( 'cancel' === $req->get_param( 'op' ) ) { Jobs::cancel( (string) $req->get_param( 'job_id' ) ); return array( 'ok' => true ); } $ids = $req->get_param( 'ids' ); $mode = sanitize_key( $req->get_param( 'mode' ) ); if ( ! is_array( $ids ) || empty( $ids ) || null === BulkMode::tryFrom( $mode ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Bad job.', 'rafgrid' ), array( 'status' => 400 ) ); } $job_id = Jobs::create( $ids, sanitize_text_field( $req->get_param( 'column' ) ), $mode, $req->get_param( 'value' ), $req->get_param( 'value2' ), absint( $req->get_param( 'run_at' ) ) ); return array( 'job_id' => $job_id, 'status' => Jobs::status( $job_id ) ); } public function bulk_job_status( $req ) { $status = Jobs::status( (string) $req->get_param( 'job_id' ) ); if ( null === $status ) { return new \WP_Error( 'rafgrid_no_job', __( 'Job not found.', 'rafgrid' ), array( 'status' => 404 ) ); } return $status; } /** * Child rows (product variations). */ public function children( $req ) { $parent = absint( $req->get_param( 'parent' ) ); if ( ! Access::can_edit_post( $parent ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $vars = get_posts( array( 'post_type' => 'product_variation', 'post_parent' => $parent, 'posts_per_page' => 100, 'post_status' => 'any', 'orderby' => 'menu_order', 'order' => 'ASC' ) ); $keys = array( 'ID', 'post_title', '_sku', '_regular_price', '_sale_price', '_stock', '_stock_status', 'menu_order' ); $rows = array(); foreach ( $vars as $v ) { $row = array(); foreach ( $keys as $k ) { $row[ $k ] = Columns::get_value( $v->ID, $k ); } $attrs = array(); foreach ( (array) get_post_meta( $v->ID ) as $mk => $mv ) { if ( str_starts_with( $mk, 'attribute_' ) ) { $attrs[ substr( $mk, 10 ) ] = $mv[0] ?? ''; } } $row['_attributes'] = $attrs; $rows[] = $row; } return array( 'rows' => $rows ); } public function audit_list( $req ) { return array( 'entries' => Audit::recent( 50 ) ); } public function audit_rollback( $req ) { $res = Audit::rollback( absint( $req->get_param( 'id' ) ) ); if ( is_wp_error( $res ) ) { return new \WP_Error( $res->get_error_code(), $res->get_error_message(), array( 'status' => 400 ) ); } return array( 'ok' => true ); } public function terms_sheet( $req ) { $tax = sanitize_key( $req->get_param( 'taxonomy' ) ?: 'category' ); if ( ! taxonomy_exists( $tax ) || ! current_user_can( get_taxonomy( $tax )->cap->edit_terms ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $terms = get_terms( array( 'taxonomy' => $tax, 'hide_empty' => false, 'number' => 200 ) ); $rows = array(); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $rows[] = array( 'ID' => $term->term_id, 'name' => $term->name, 'slug' => urldecode( $term->slug ), 'description' => $term->description, 'count' => $term->count ); } } $taxs = array(); foreach ( get_taxonomies( array( 'show_ui' => true ), 'objects' ) as $slug => $obj ) { $taxs[ $slug ] = $obj->label; } return array( 'rows' => $rows, 'taxonomies' => $taxs ); } public function term_cell( $req ) { $tax = sanitize_key( $req->get_param( 'taxonomy' ) ); $id = absint( $req->get_param( 'id' ) ); $column = sanitize_key( $req->get_param( 'column' ) ); if ( ! taxonomy_exists( $tax ) || ! current_user_can( get_taxonomy( $tax )->cap->edit_terms ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $term = get_term( $id, $tax ); if ( ! $term || is_wp_error( $term ) || ! current_user_can( 'edit_term', $term->term_id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } if ( ! in_array( $column, array( 'name', 'slug', 'description' ), true ) ) { return new \WP_Error( 'rafgrid_readonly', __( 'Read-only column.', 'rafgrid' ), array( 'status' => 400 ) ); } $value = 'slug' === $column ? sanitize_title( wp_unslash( (string) $req->get_param( 'value' ) ) ) : sanitize_text_field( wp_unslash( (string) $req->get_param( 'value' ) ) ); $res = wp_update_term( $id, $tax, array( $column => $value ) ); if ( is_wp_error( $res ) ) { return new \WP_Error( 'rafgrid_term_failed', $res->get_error_message(), array( 'status' => 400 ) ); } return array( 'ok' => true ); } public function term_create( $req ) { $tax = sanitize_key( $req->get_param( 'taxonomy' ) ); if ( ! taxonomy_exists( $tax ) || ! current_user_can( get_taxonomy( $tax )->cap->edit_terms ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } if ( function_exists( 'wc_get_product' ) && 'product_type' === $tax ) { // Never create product types from the sheet. return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $name = sanitize_text_field( wp_unslash( (string) $req->get_param( 'name' ) ) ); if ( '' === $name ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing name.', 'rafgrid' ), array( 'status' => 400 ) ); } // Reuse existing term with same name. $existing = get_term_by( 'name', $name, $tax ); if ( $existing ) { return array( 'id' => $existing->term_id, 'name' => $existing->name ); } $res = wp_insert_term( $name, $tax ); if ( is_wp_error( $res ) ) { // Name exists under different case etc. $by_slug = get_term_by( 'slug', sanitize_title( $name ), $tax ); if ( $by_slug ) { return array( 'id' => $by_slug->term_id, 'name' => $by_slug->name ); } return new \WP_Error( 'rafgrid_term_failed', $res->get_error_message(), array( 'status' => 400 ) ); } return array( 'id' => (int) $res['term_id'], 'name' => $name ); } public function comments_sheet( $req ) { if ( ! current_user_can( 'moderate_comments' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $status = sanitize_key( $req->get_param( 'status' ) ?: 'all' ); $comments = get_comments( array( 'number' => 50, 'status' => 'all' === $status ? 'all' : $status ) ); $rows = array(); foreach ( $comments as $c ) { $rows[] = array( 'ID' => (int) $c->comment_ID, 'author' => $c->comment_author, 'email' => $c->comment_author_email, 'content' => wp_trim_words( $c->comment_content, 18 ), 'status' => wp_get_comment_status( $c->comment_ID ), 'date' => $c->comment_date, 'post' => get_the_title( (int) $c->comment_post_ID ), ); } return array( 'rows' => $rows ); } public function comment_action( $req ) { if ( ! current_user_can( 'moderate_comments' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $id = absint( $req->get_param( 'id' ) ); $action = sanitize_key( $req->get_param( 'action_name' ) ); $map = array( 'approve' => 'approve', 'hold' => 'hold', 'spam' => 'spam', 'trash' => 'trash', 'unspam' => 'approve' ); if ( ! isset( $map[ $action ] ) || ! get_comment( $id ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Bad comment action.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( ! current_user_can( 'edit_comment', $id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } wp_set_comment_status( $id, $map[ $action ] ); return array( 'ok' => true ); } public function lock_row( $req ) { $id = absint( $req->get_param( 'id' ) ); if ( ! $id || ! Access::can_edit_post( $id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } set_transient( 'rafgrid_lock_' . $id, array( 'user' => get_current_user_id(), 'time' => time() ), 90 ); return array( 'ok' => true ); } public function feed_sync( $req ) { $gate = Security::require_admin(); if ( is_wp_error( $gate ) ) { return $gate; } $url = (string) $req->get_param( 'url' ); if ( '' === $url ) { $url = (string) $this->settings->get( 'feed_url', '' ); } $res = Feed::sync_from_url( $url ); if ( is_wp_error( $res ) ) { return new \WP_Error( $res->get_error_code(), $res->get_error_message(), array( 'status' => 400 ) ); } return $res; } public function snapshot_list( $req ) { $gate = Security::require_admin(); if ( is_wp_error( $gate ) ) { return $gate; } return array( 'snapshots' => Snapshot::listing() ); } public function snapshot_op( $req ) { $gate = Security::require_admin(); if ( is_wp_error( $gate ) ) { return $gate; } $op = sanitize_key( $req->get_param( 'op' ) ?: 'create' ); if ( 'restore' === $op ) { return Snapshot::restore( (string) $req->get_param( 'file' ) ); } $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $res = Snapshot::create( $type ); return is_wp_error( $res ) ? new \WP_Error( $res->get_error_code(), $res->get_error_message(), array( 'status' => 400 ) ) : $res; } public function ai_generate( $req ) { $ids = array_map( 'absint', (array) $req->get_param( 'ids' ) ); $task = sanitize_key( $req->get_param( 'task' ) ?: 'description' ); $done = 0; $errors = array(); foreach ( array_slice( $ids, 0, 20 ) as $id ) { // Never send an item's content to an AI provider unless the user may edit it. if ( ! Access::can_edit_post( $id ) ) { $errors[] = __( 'You cannot edit this item.', 'rafgrid' ) . ' #' . $id; continue; } $text = AI::generate( $task, $id ); if ( is_wp_error( $text ) ) { $errors[] = $text->get_error_message(); break; } $col = match ( $task ) { 'seo_title' => 'meta:_yoast_wpseo_title', 'seo_desc' => 'meta:_yoast_wpseo_metadesc', default => 'post_excerpt', }; $res = Saving::save_cell( $id, $col, $text ); if ( ! is_wp_error( $res ) ) { ++$done; } } return array( 'done' => $done, 'errors' => array_slice( $errors, 0, 3 ) ); } public function health( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $posts = get_posts( array( 'post_type' => $type, 'posts_per_page' => 500, 'post_status' => 'any', 'fields' => 'ids' ) ); $out = array( 'total' => count( $posts ), 'no_title' => 0, 'no_image' => 0, 'no_content' => 0, 'no_sku' => 0, 'no_price' => 0, 'dup_titles' => 0 ); $seen = array(); foreach ( $posts as $id ) { $post = get_post( $id ); if ( '' === trim( (string) $post->post_title ) ) { ++$out['no_title']; } if ( isset( $seen[ $post->post_title ] ) ) { ++$out['dup_titles']; } $seen[ $post->post_title ] = true; if ( ! (int) get_post_meta( $id, '_thumbnail_id', true ) ) { ++$out['no_image']; } if ( '' === trim( wp_strip_all_tags( (string) $post->post_content ) ) ) { ++$out['no_content']; } if ( 'product' === $type ) { if ( '' === (string) get_post_meta( $id, '_sku', true ) ) { ++$out['no_sku']; } if ( '' === (string) get_post_meta( $id, '_regular_price', true ) ) { ++$out['no_price']; } } } return $out; } public function users_sheet( $req ) { if ( ! current_user_can( 'list_users' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $users = get_users( array( 'number' => 100 ) ); $rows = array(); foreach ( $users as $u ) { $row = array( 'ID' => $u->ID, 'user_login' => $u->user_login, 'display_name' => $u->display_name, 'user_email' => $u->user_email, 'roles' => implode( ',', (array) $u->roles ), 'user_registered' => $u->user_registered ); if ( function_exists( 'wc_get_customer_total_spent' ) ) { $row['total_spent'] = wc_get_customer_total_spent( $u->ID ); } $rows[] = $row; } return array( 'rows' => $rows ); } /** * Edit one user cell from the Users sheet (wp-admin only, never via MCP). * Mirrors core user-edit.php: edit_user on the target account, promote_user * plus an editable role for role changes, and no changing your own role. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function user_cell( $req ) { $id = absint( $req->get_param( 'id' ) ); $col = sanitize_key( $req->get_param( 'column' ) ); $val = $req->get_param( 'value' ); $user = get_user_by( 'id', $id ); if ( ! $user ) { return new \WP_Error( 'rafgrid_not_found', __( 'User not found.', 'rafgrid' ), array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_user', $id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } if ( 'role' === $col ) { $role = sanitize_key( (string) $val ); if ( get_current_user_id() === $id ) { return new \WP_Error( 'rafgrid_own_role', __( 'You cannot change your own role.', 'rafgrid' ), array( 'status' => 403 ) ); } if ( ! current_user_can( 'promote_user', $id ) || ( is_multisite() && is_super_admin( $id ) ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } if ( ! array_key_exists( $role, get_editable_roles() ) ) { return new \WP_Error( 'rafgrid_bad_role', __( 'Unknown role.', 'rafgrid' ), array( 'status' => 400 ) ); } $user->set_role( $role ); return array( 'ok' => true ); } if ( ! in_array( $col, array( 'display_name', 'user_email' ), true ) ) { return new \WP_Error( 'rafgrid_readonly', __( 'Read-only column.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( 'user_email' === $col ) { $email = sanitize_email( (string) $val ); if ( ! is_email( $email ) ) { return new \WP_Error( 'rafgrid_bad_email', __( 'Invalid email address.', 'rafgrid' ), array( 'status' => 400 ) ); } $owner = email_exists( $email ); if ( $owner && (int) $owner !== $id ) { return new \WP_Error( 'rafgrid_email_taken', __( 'This email address is already used by another user.', 'rafgrid' ), array( 'status' => 400 ) ); } } $res = wp_update_user( array( 'ID' => $id, $col => 'user_email' === $col ? sanitize_email( (string) $val ) : sanitize_text_field( wp_unslash( (string) $val ) ) ) ); return is_wp_error( $res ) ? new \WP_Error( 'rafgrid_user_failed', $res->get_error_message(), array( 'status' => 400 ) ) : array( 'ok' => true ); } public function orders_sheet( $req ) { if ( ! function_exists( 'wc_get_orders' ) || ! current_user_can( 'edit_shop_orders' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $orders = wc_get_orders( array( 'limit' => 50, 'orderby' => 'date', 'order' => 'DESC', 'status' => array_keys( wc_get_order_statuses() ) ) ); $rows = array(); foreach ( $orders as $o ) { $rows[] = array( 'ID' => $o->get_id(), 'status' => $o->get_status(), 'total' => $o->get_total(), 'currency' => $o->get_currency(), 'customer' => $o->get_billing_email(), 'date' => $o->get_date_created() ? $o->get_date_created()->format( 'Y-m-d H:i' ) : '', 'items' => $o->get_item_count() ); } return array( 'rows' => $rows ); } public function order_action( $req ) { if ( ! function_exists( 'wc_get_order' ) || ! current_user_can( 'edit_shop_orders' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $order = wc_get_order( absint( $req->get_param( 'id' ) ) ); if ( ! $order ) { return new \WP_Error( 'rafgrid_not_found', __( 'Order not found.', 'rafgrid' ), array( 'status' => 404 ) ); } $status = sanitize_key( (string) $req->get_param( 'status' ) ); if ( ! array_key_exists( 'wc-' . $status, wc_get_order_statuses() ) && ! array_key_exists( $status, wc_get_order_statuses() ) ) { return new \WP_Error( 'rafgrid_bad_status', __( 'Unknown order status.', 'rafgrid' ), array( 'status' => 400 ) ); } $order->update_status( $status ); return array( 'ok' => true ); } public function variations_generate( $req ) { $parent = absint( $req->get_param( 'parent' ) ); if ( ! function_exists( 'wc_get_product' ) || ! Access::can_edit_post( $parent ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $product = wc_get_product( $parent ); if ( ! $product ) { return new \WP_Error( 'rafgrid_not_found', __( 'Product not found.', 'rafgrid' ), array( 'status' => 404 ) ); } $sets = array(); foreach ( $product->get_attributes() as $attr ) { if ( ! $attr->get_variation() ) { continue; } $name = $attr->is_taxonomy() ? $attr->get_name() : sanitize_title( $attr->get_name() ); $vals = array(); foreach ( (array) $attr->get_options() as $opt ) { if ( $attr->is_taxonomy() ) { $term = get_term( (int) $opt ); if ( $term ) { $vals[] = $term->slug; } } else { $vals[] = sanitize_title( (string) $opt ); } } if ( $vals ) { $sets[ 'attribute_' . $name ] = $vals; } } if ( ! $sets ) { return new \WP_Error( 'rafgrid_no_attrs', __( 'No variation attributes. Mark attributes as "Used for variations" first.', 'rafgrid' ), array( 'status' => 400 ) ); } $combos = array( array() ); foreach ( $sets as $key => $vals ) { $next = array(); foreach ( $combos as $combo ) { foreach ( $vals as $v ) { $c = $combo; $c[ $key ] = $v; $next[] = $c; } } $combos = array_slice( $next, 0, 50 ); } $created = 0; foreach ( $combos as $combo ) { $vid = wp_insert_post( array( 'post_type' => 'product_variation', 'post_parent' => $parent, 'post_status' => 'publish', 'post_title' => 'Variation' ) ); if ( is_wp_error( $vid ) ) { continue; } foreach ( $combo as $k => $v ) { update_post_meta( $vid, $k, $v ); } ++$created; } return array( 'created' => $created ); } public function import_image( $req ) { $id = absint( $req->get_param( 'id' ) ); $url = esc_url_raw( (string) $req->get_param( 'url' ) ); $post = get_post( $id ); if ( ! $post || '' === $url ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Bad request.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( ! Access::can_edit_post( $id ) || ! current_user_can( 'upload_files' ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $att = media_sideload_image( $url, $id, $post->post_title, 'id' ); if ( is_wp_error( $att ) && ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rafgrid_image_failed', $att->get_error_message(), array( 'status' => 400 ) ); } if ( is_wp_error( $att ) ) { // Administrators only: fallback for URLs WordPress rejects in download_url // (localhost/intranet on dev sites and supplier LANs). Editors always go // through core's safe download, which refuses private network addresses. $valid = Security::validate_outbound_url( $url ); if ( is_wp_error( $valid ) ) { return $valid; } $resp = wp_remote_get( $url, array( 'timeout' => 30 ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { return new \WP_Error( 'rafgrid_image_failed', $att->get_error_message(), array( 'status' => 400 ) ); } $ext = strtolower( pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) ) { $ext = 'jpg'; } $tmp = wp_tempnam( 'rafgrid-img' ) . '.' . $ext; file_put_contents( $tmp, wp_remote_retrieve_body( $resp ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $att = media_handle_sideload( array( 'name' => basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ) ?: ( 'image.' . $ext ), 'tmp_name' => $tmp ), $id ); if ( is_wp_error( $att ) ) { return new \WP_Error( 'rafgrid_image_failed', $att->get_error_message(), array( 'status' => 400 ) ); } } $att = (int) $att; $alt = sanitize_text_field( wp_unslash( (string) $req->get_param( 'alt' ) ) ); if ( '' !== $alt ) { update_post_meta( $att, '_wp_attachment_image_alt', $alt ); } $featured = $req->get_param( 'featured' ); if ( null === $featured || filter_var( $featured, FILTER_VALIDATE_BOOLEAN ) ) { update_post_meta( $id, '_thumbnail_id', $att ); } if ( filter_var( $req->get_param( 'insert' ), FILTER_VALIDATE_BOOLEAN ) ) { $img_url = wp_get_attachment_image_url( $att, 'large' ) ?: wp_get_attachment_url( $att ); if ( $img_url ) { $figure = '
' . esc_attr( '' !== $alt ? $alt : $post->post_title ) . '
'; wp_update_post( array( 'ID' => $id, 'post_content' => rtrim( (string) $post->post_content ) . "\n" . $figure ) ); } } return array( 'ok' => true, 'attachment_id' => $att, 'url' => wp_get_attachment_url( $att ) ); } public function bulk_alt( $req ) { $ids = array_map( 'absint', (array) $req->get_param( 'ids' ) ); $done = 0; foreach ( array_slice( $ids, 0, 200 ) as $id ) { $post = get_post( $id ); $tid = (int) get_post_meta( $id, '_thumbnail_id', true ); if ( ! $post || ! $tid || ! Access::can_edit_post( $id ) || ! current_user_can( 'edit_post', $tid ) ) { continue; } if ( '' === (string) get_post_meta( $tid, '_wp_attachment_image_alt', true ) ) { update_post_meta( $tid, '_wp_attachment_image_alt', $post->post_title ); ++$done; } } return array( 'done' => $done ); } public function rules_list( $req ) { return array( 'rules' => Rules::all() ); } public function rules_save( $req ) { $gate = Security::require_admin(); if ( is_wp_error( $gate ) ) { return $gate; } $op = sanitize_key( $req->get_param( 'op' ) ?: 'save' ); if ( 'run' === $op ) { $id = sanitize_key( $req->get_param( 'id' ) ); foreach ( Rules::all() as $rule ) { if ( $rule['id'] === $id ) { return Rules::run_rule( $rule ); } } return new \WP_Error( 'rafgrid_no_rule', __( 'Rule not found.', 'rafgrid' ), array( 'status' => 404 ) ); } if ( 'run_all' === $op ) { Rules::run_all_now(); return array( 'ok' => true ); } $rules = $req->get_param( 'rules' ); if ( ! is_array( $rules ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Rules must be a list.', 'rafgrid' ), array( 'status' => 400 ) ); } return array( 'rules' => Rules::save_all( $rules ) ); } public function presence( $req ) { $map = get_option( 'rafgrid_presence', array() ); if ( ! is_array( $map ) ) { $map = array(); } $uid = get_current_user_id(); $user = wp_get_current_user(); $map[ $uid ] = array( 'name' => $user ? $user->display_name : '', 'time' => time(), 'type' => sanitize_key( $req->get_param( 'post_type' ) ?: '' ) ); foreach ( $map as $k => $v ) { if ( ( $v['time'] ?? 0 ) < time() - 120 ) { unset( $map[ $k ] ); } } update_option( 'rafgrid_presence', $map, false ); return array( 'users' => array_values( $map ) ); } public function notes_get( $req ) { $id = absint( $req->get_param( 'id' ) ); if ( ! $id || ! Access::can_edit_post( $id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $notes = get_post_meta( $id, '_rafgrid_notes', true ); return array( 'notes' => is_array( $notes ) ? array_values( $notes ) : array() ); } public function notes_add( $req ) { $id = absint( $req->get_param( 'id' ) ); if ( ! Access::can_edit_post( $id ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $text = sanitize_textarea_field( wp_unslash( (string) $req->get_param( 'note' ) ) ); if ( '' === $text ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Empty note.', 'rafgrid' ), array( 'status' => 400 ) ); } $notes = get_post_meta( $id, '_rafgrid_notes', true ); $notes = is_array( $notes ) ? $notes : array(); $user = wp_get_current_user(); $notes[] = array( 'user' => $user ? $user->display_name : '', 'time' => current_time( 'mysql' ), 'text' => $text ); update_post_meta( $id, '_rafgrid_notes', array_slice( $notes, -50 ) ); return array( 'notes' => array_values( $notes ) ); } public function merge_posts( $req ) { $from = absint( $req->get_param( 'from_id' ) ); $to = absint( $req->get_param( 'to_id' ) ); $a = get_post( $from ); $b = get_post( $to ); if ( ! $a || ! $b || $from === $to || $a->post_type !== $b->post_type ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Bad merge pair.', 'rafgrid' ), array( 'status' => 400 ) ); } // The target is edited and the source is trashed: both rights are required. if ( ! Access::can_edit_post( $to ) || ! Access::can_delete_post( $from ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } foreach ( (array) get_post_meta( $from ) as $k => $vals ) { if ( '' === (string) get_post_meta( $to, $k, true ) ) { foreach ( (array) $vals as $v ) { add_post_meta( $to, $k, maybe_unserialize( $v ) ); } } } foreach ( get_object_taxonomies( $a->post_type ) as $tax ) { $ids = wp_get_post_terms( $from, $tax, array( 'fields' => 'ids' ) ); if ( ! is_wp_error( $ids ) && $ids ) { wp_set_post_terms( $to, array_map( 'intval', $ids ), $tax, true ); } } $old_link = get_permalink( $from ); wp_trash_post( $from ); $new_link = get_permalink( $to ); if ( $old_link && $new_link && $old_link !== $new_link ) { $map = get_option( 'rafgrid_redirects', array() ); if ( ! is_array( $map ) ) { $map = array(); } $map[ trim( (string) wp_parse_url( $old_link, PHP_URL_PATH ), '/' ) ] = trim( (string) wp_parse_url( $new_link, PHP_URL_PATH ), '/' ); update_option( 'rafgrid_redirects', $map, false ); } return array( 'ok' => true, 'into' => $to ); } public function config_export( $req ) { $uid = get_current_user_id(); $data = array( 'settings' => ( new Settings() )->all(), 'presets' => self::get_presets(), 'views' => array(), 'columns' => array(), 'rules' => Rules::all(), ); unset( $data['settings']['ai_api_key'], $data['settings']['scheduled_owner'] ); // Site settings (webhook, feed URL, merchant feed key) are for administrators only. if ( ! current_user_can( 'manage_options' ) ) { unset( $data['settings'] ); } foreach ( Post_Types::editable() as $slug => $label ) { $v = get_user_meta( $uid, 'rafgrid_views_' . $slug, true ); if ( is_array( $v ) && $v ) { $data['views'][ $slug ] = $v; } $c = get_user_meta( $uid, 'rafgrid_columns_' . $slug, true ); if ( is_array( $c ) && $c ) { $data['columns'][ $slug ] = $c; } } return $data; } public function config_import( $req ) { $data = $req->get_param( 'config' ); if ( ! is_array( $data ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Bad config.', 'rafgrid' ), array( 'status' => 400 ) ); } $uid = get_current_user_id(); if ( isset( $data['presets'] ) && is_array( $data['presets'] ) ) { update_user_meta( $uid, 'rafgrid_presets', $data['presets'] ); } foreach ( (array) ( $data['views'] ?? array() ) as $slug => $v ) { if ( post_type_exists( sanitize_key( $slug ) ) && is_array( $v ) ) { update_user_meta( $uid, 'rafgrid_views_' . sanitize_key( $slug ), $v ); } } foreach ( (array) ( $data['columns'] ?? array() ) as $slug => $v ) { if ( post_type_exists( sanitize_key( $slug ) ) && is_array( $v ) ) { update_user_meta( $uid, 'rafgrid_columns_' . sanitize_key( $slug ), $v ); } } if ( isset( $data['rules'] ) && is_array( $data['rules'] ) && current_user_can( 'manage_options' ) ) { Rules::save_all( $data['rules'] ); } return array( 'ok' => true ); } public function linkcheck( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $posts = get_posts( array( 'post_type' => $type, 'posts_per_page' => 100, 'post_status' => 'publish', 'fields' => 'ids' ) ); $urls = array(); foreach ( $posts as $id ) { if ( preg_match_all( '/https?:\\/\\/[^\\s"\'<>]+/i', (string) get_post( $id )->post_content, $m ) ) { foreach ( $m[0] as $u ) { $urls[ rtrim( $u, '.,)' ) ] = $id; } } } $broken = array(); foreach ( array_slice( $urls, 0, 20, true ) as $u => $pid ) { if ( is_wp_error( Security::validate_outbound_url( $u ) ) ) { continue; } // wp_safe_remote_head refuses private and loopback addresses. $res = wp_safe_remote_head( $u, array( 'timeout' => 5, 'redirection' => 3 ) ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); if ( $code >= 400 || 0 === $code ) { $broken[] = array( 'url' => $u, 'post_id' => $pid, 'status' => $code ); } } return array( 'checked' => min( 20, count( $urls ) ), 'found_urls' => count( $urls ), 'broken' => $broken ); } public function attributes_get( $req ) { $parent = absint( $req->get_param( 'parent' ) ); if ( ! function_exists( 'wc_get_product' ) ) { return new \WP_Error( 'rafgrid_no_woo', __( 'WooCommerce required.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( ! Access::can_edit_post( $parent ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $product = wc_get_product( $parent ); if ( ! $product ) { return new \WP_Error( 'rafgrid_not_found', __( 'Product not found.', 'rafgrid' ), array( 'status' => 404 ) ); } $out = array(); foreach ( $product->get_attributes() as $attr ) { $out[] = array( 'name' => $attr->get_name(), 'options' => array_map( 'strval', (array) $attr->get_options() ), 'variation' => $attr->get_variation() ? 1 : 0, 'taxonomy' => $attr->is_taxonomy() ? 1 : 0, ); } return array( 'attributes' => $out ); } public function attributes_save( $req ) { $parent = absint( $req->get_param( 'parent' ) ); if ( ! function_exists( 'wc_get_product' ) || ! Access::can_edit_post( $parent ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'Forbidden.', 'rafgrid' ), array( 'status' => 403 ) ); } $product = wc_get_product( $parent ); if ( ! $product || ! method_exists( $product, 'set_attributes' ) ) { return new \WP_Error( 'rafgrid_not_found', __( 'Product not found.', 'rafgrid' ), array( 'status' => 404 ) ); } $attrs = array(); foreach ( (array) $req->get_param( 'attributes' ) as $a ) { if ( ! is_array( $a ) || empty( $a['name'] ) ) { continue; } $name = sanitize_text_field( $a['name'] ); $attr = new \WC_Product_Attribute(); $attr->set_id( 0 ); $attr->set_name( $name ); $attr->set_options( array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $a['options'] ?? array() ) ) ) ) ); $attr->set_visible( true ); $attr->set_variation( ! empty( $a['variation'] ) ); $attrs[ sanitize_title( $name ) ] = $attr; } $product->set_attributes( $attrs ); $product->save(); return array( 'ok' => true, 'count' => count( $attrs ) ); } /** * MCP (Model Context Protocol) JSON-RPC endpoint. * Supports single messages and batches. Notifications get 202 with no body. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response */ public function mcp_endpoint( $req ) { $fresh = new Settings(); if ( ! $fresh->get( 'enable_mcp', 1 ) ) { $res = new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => null, 'error' => array( 'code' => -32000, 'message' => 'MCP is disabled in Rafgrid settings.' ) ), 403 ); return $res; } $ips = array_filter( array_map( 'trim', explode( ',', (string) $fresh->get( 'mcp_ip_allowlist', '' ) ) ) ); if ( ! empty( $ips ) ) { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! in_array( $remote, $ips, true ) ) { return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => null, 'error' => array( 'code' => -32000, 'message' => 'IP not allowed for MCP.' ) ), 403 ); } } $payload = json_decode( (string) $req->get_body(), true ); if ( ! is_array( $payload ) ) { return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => null, 'error' => array( 'code' => -32700, 'message' => 'Parse error.' ) ), 400 ); } $is_batch = array_values( $payload ) === $payload && isset( $payload[0] ); $has_call = $is_batch ? (bool) array_filter( $payload, static fn( $m ) => is_array( $m ) && ( $m['method'] ?? '' ) === 'tools/call' ) : ( ( $payload['method'] ?? '' ) === 'tools/call' ); if ( $has_call && ! MCP::rate_allowed( $fresh ) ) { return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => null, 'error' => array( 'code' => -32000, 'message' => 'MCP rate limit exceeded. Try again in a minute.' ) ), 429 ); } if ( $is_batch ) { $out = array(); foreach ( $payload as $msg ) { if ( is_array( $msg ) ) { $r = MCP::handle_message( $msg ); if ( null !== $r ) { $out[] = $r; } } } return new \WP_REST_Response( $out, 200 ); } $out = MCP::handle_message( $payload ); if ( null === $out ) { return new \WP_REST_Response( null, 202 ); } return new \WP_REST_Response( $out, 200 ); } /** * Bootstrap: types, settings, history. * * @param \WP_REST_Request $req Request. * @return array */ public function bootstrap( $req ) { $types = Post_Types::editable(); return array( 'types' => $types, 'settings' => array( 'default_post_type' => $this->settings->get( 'default_post_type', 'post' ), 'per_page' => (int) $this->settings->get( 'per_page', 50 ), 'autosave' => (int) $this->settings->get( 'autosave', 0 ), 'enable_woo' => (int) $this->settings->get( 'enable_woo', 1 ), 'enable_history' => (int) $this->settings->get( 'enable_history', 1 ), 'has_woo' => Plugin::has_woo() ? 1 : 0, 'has_acf' => function_exists( 'acf_get_field_groups' ) ? 1 : 0, 'has_polylang' => function_exists( 'pll_get_post_language' ) ? 1 : 0, 'can_manage' => current_user_can( 'manage_options' ) ? 1 : 0, ), 'history' => History::recent(), 'author' => Author::contacts(), ); } /** * Columns for type. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function columns( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $cols = Columns::get_columns( $type ); // Enrich taxonomy columns with terms for the editor. foreach ( $cols as $k => $c ) { if ( 'taxonomy' === $c['type'] && ! empty( $c['taxonomy'] ) ) { $terms = get_terms( array( 'taxonomy' => $c['taxonomy'], 'hide_empty' => false, 'number' => 200, ) ); $opts = array(); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $t ) { $opts[ (string) $t->term_id ] = $t->name; } } $cols[ $k ]['options'] = $opts; } } return array( 'columns' => array_values( $cols ) ); } /** * Rows. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function rows( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $params = $req->get_params(); // Normalize array-ish tax filters from query string. foreach ( $params as $k => $v ) { if ( str_starts_with( $k, 'tax_' ) && is_string( $v ) && str_contains( $v, ',' ) ) { $params[ $k ] = array_filter( array_map( 'trim', explode( ',', $v ) ) ); } } if ( isset( $params['columns'] ) && is_string( $params['columns'] ) ) { $params['columns'] = array_filter( array_map( 'trim', explode( ',', $params['columns'] ) ) ); } $data = Query::fetch( $type, $params ); return $data; } /** * Save one cell. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function save_cell( $req ) { $id = absint( $req->get_param( 'id' ) ); $column = sanitize_text_field( $req->get_param( 'column' ) ); $value = $req->get_param( 'value' ); if ( ! $id || '' === $column ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing id or column.', 'rafgrid' ), array( 'status' => 400 ) ); } $res = Saving::save_cell( $id, $column, $value ); if ( is_wp_error( $res ) ) { return new \WP_Error( $res->get_error_code(), $res->get_error_message(), array( 'status' => 400 ) ); } return array( 'ok' => true, 'value' => Columns::get_value( $id, $column ), ); } /** * Bulk. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function bulk( $req ) { $ids = $req->get_param( 'ids' ); $column = sanitize_text_field( $req->get_param( 'column' ) ); $mode = sanitize_key( $req->get_param( 'mode' ) ); $value = $req->get_param( 'value' ); $value2 = $req->get_param( 'value2' ); if ( ! is_array( $ids ) || empty( $ids ) || '' === $column || '' === $mode ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing ids, column or mode.', 'rafgrid' ), array( 'status' => 400 ) ); } if ( null === BulkMode::tryFrom( $mode ) ) { return new \WP_Error( 'rafgrid_bad_mode', __( 'Unknown bulk mode.', 'rafgrid' ), array( 'status' => 400 ) ); } $max = (int) $this->settings->get( 'max_bulk', 500 ); if ( count( $ids ) > $max ) { /* translators: %d: maximum number of items per bulk operation */ return new \WP_Error( 'rafgrid_too_many', sprintf( __( 'Too many items (max %d).', 'rafgrid' ), $max ), array( 'status' => 400 ) ); } return Saving::bulk( $ids, $column, $mode, $value, $value2 ); } /** * Row actions: trash, restore, delete, duplicate. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function row_action( $req ) { $ids = array_map( 'absint', (array) $req->get_param( 'ids' ) ); $action = sanitize_key( $req->get_param( 'action_name' ) ?: $req->get_param( 'sub_action' ) ?: '' ); if ( empty( $ids ) || '' === $action ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing ids or action.', 'rafgrid' ), array( 'status' => 400 ) ); } $done = 0; $new = array(); foreach ( $ids as $id ) { if ( ! get_post( $id ) ) { continue; } switch ( $action ) { case 'trash': if ( Access::can_delete_post( $id ) && wp_trash_post( $id ) ) { ++$done; } break; case 'restore': if ( Access::can_delete_post( $id ) && wp_untrash_post( $id ) ) { ++$done; } break; case 'delete': if ( Access::can_delete_post( $id ) && wp_delete_post( $id, true ) ) { ++$done; } break; case 'duplicate': $nid = Saving::duplicate( $id ); if ( ! is_wp_error( $nid ) ) { ++$done; $new[] = $nid; } break; } } return array( 'done' => $done, 'new_ids' => $new ); } /** * Create new item. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function create( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } if ( ! current_user_can( get_post_type_object( $type )->cap->create_posts ) ) { return new \WP_Error( 'rafgrid_forbidden', __( 'You cannot create items of this type.', 'rafgrid' ), array( 'status' => 403 ) ); } $title = sanitize_text_field( wp_unslash( (string) $req->get_param( 'title' ) ) ); if ( '' === $title ) { $title = __( '(no title)', 'rafgrid' ); } $id = wp_insert_post( array( 'post_title' => $title, 'post_status' => 'draft', 'post_type' => $type, ), true ); if ( is_wp_error( $id ) ) { return new \WP_Error( 'rafgrid_create_failed', $id->get_error_message(), array( 'status' => 400 ) ); } return array( 'id' => (int) $id ); } /** * Column prefs get. * * @param \WP_REST_Request $req Request. * @return array */ public function prefs_get( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $prefs = get_user_meta( get_current_user_id(), 'rafgrid_columns_' . $type, true ); return array( 'prefs' => is_array( $prefs ) ? $prefs : array() ); } /** * Column prefs save. * * @param \WP_REST_Request $req Request. * @return array */ public function prefs_save( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $prefs = $req->get_param( 'prefs' ); if ( ! is_array( $prefs ) ) { $prefs = array(); } // Sanitize: array of column keys in order + hidden list. $clean = array( 'order' => array(), 'hidden' => array(), 'widths' => array(), ); if ( isset( $prefs['widths'] ) && is_array( $prefs['widths'] ) ) { foreach ( array_slice( $prefs['widths'], 0, 100, true ) as $k => $w ) { $k = sanitize_text_field( $k ); $w = max( 60, min( 600, absint( $w ) ) ); if ( '' !== $k && $w ) { $clean['widths'][ $k ] = $w; } } } if ( isset( $prefs['order'] ) && is_array( $prefs['order'] ) ) { foreach ( array_slice( $prefs['order'], 0, 200 ) as $k ) { $k = sanitize_text_field( $k ); if ( '' !== $k ) { $clean['order'][] = $k; } } } if ( isset( $prefs['hidden'] ) && is_array( $prefs['hidden'] ) ) { foreach ( array_slice( $prefs['hidden'], 0, 200 ) as $k ) { $k = sanitize_text_field( $k ); if ( '' !== $k ) { $clean['hidden'][] = $k; } } } update_user_meta( get_current_user_id(), 'rafgrid_columns_' . $type, $clean ); return array( 'ok' => true ); } /** * Views list. * * @return array */ public function views_get( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $views = get_user_meta( get_current_user_id(), 'rafgrid_views_' . $type, true ); return array( 'views' => is_array( $views ) ? array_values( $views ) : array() ); } /** * Save/delete view. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function views_save( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $op = sanitize_key( $req->get_param( 'op' ) ?: 'save' ); $views = get_user_meta( get_current_user_id(), 'rafgrid_views_' . $type, true ); $views = is_array( $views ) ? $views : array(); if ( 'delete' === $op ) { $name = sanitize_text_field( $req->get_param( 'name' ) ); unset( $views[ $name ] ); update_user_meta( get_current_user_id(), 'rafgrid_views_' . $type, $views ); return array( 'ok' => true, 'views' => array_values( $views ) ); } $name = sanitize_text_field( $req->get_param( 'name' ) ); $data = $req->get_param( 'data' ); if ( '' === $name || ! is_array( $data ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing view name or data.', 'rafgrid' ), array( 'status' => 400 ) ); } $views[ substr( $name, 0, 60 ) ] = array( 'name' => substr( $name, 0, 60 ), 'filters' => $data, ); update_user_meta( get_current_user_id(), 'rafgrid_views_' . $type, array_slice( $views, -20, 20, true ) ); return array( 'ok' => true, 'views' => array_values( $views ) ); } /** * Column presets list (per user, shared across post types). * * @return array */ public function presets_get() { return array( 'presets' => self::get_presets() ); } /** * Save or delete a column preset. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function presets_save( $req ) { $op = sanitize_key( $req->get_param( 'op' ) ?: 'save' ); $presets = self::get_presets(); if ( 'delete' === $op ) { $name = substr( sanitize_text_field( $req->get_param( 'name' ) ), 0, 60 ); $presets = array_values( array_filter( $presets, function ( $p ) use ( $name ) { return $p['name'] !== $name; } ) ); update_user_meta( get_current_user_id(), 'rafgrid_presets', $presets ); return array( 'ok' => true, 'presets' => $presets ); } $name = substr( sanitize_text_field( $req->get_param( 'name' ) ), 0, 60 ); $data = $req->get_param( 'preset' ); if ( '' === $name || ! is_array( $data ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Missing preset name or data.', 'rafgrid' ), array( 'status' => 400 ) ); } $entry = array( 'name' => $name, 'order' => self::clean_key_list( isset( $data['order'] ) ? $data['order'] : array() ), 'hidden' => self::clean_key_list( isset( $data['hidden'] ) ? $data['hidden'] : array() ), ); $found = false; foreach ( $presets as $i => $p ) { if ( $p['name'] === $name ) { $presets[ $i ] = $entry; $found = true; break; } } if ( ! $found ) { $presets[] = $entry; } $presets = array_slice( $presets, -30, 30 ); update_user_meta( get_current_user_id(), 'rafgrid_presets', array_values( $presets ) ); return array( 'ok' => true, 'presets' => array_values( $presets ) ); } /** * Get presets for current user. * * @return array */ private static function get_presets() { $presets = get_user_meta( get_current_user_id(), 'rafgrid_presets', true ); if ( ! is_array( $presets ) ) { return array(); } $out = array(); foreach ( array_slice( $presets, 0, 30 ) as $p ) { if ( ! is_array( $p ) || empty( $p['name'] ) ) { continue; } $out[] = array( 'name' => substr( (string) $p['name'], 0, 60 ), 'order' => self::clean_key_list( isset( $p['order'] ) ? $p['order'] : array() ), 'hidden' => self::clean_key_list( isset( $p['hidden'] ) ? $p['hidden'] : array() ), ); } return $out; } /** * Sanitize a list of column keys. * * @param mixed $arr Raw. * @return string[] */ private static function clean_key_list( $arr ) { $out = array(); foreach ( (array) $arr as $k ) { $k = trim( (string) $k ); if ( preg_match( '/^[A-Za-z0-9_:\\-]{1,100}$/', $k ) ) { $out[] = $k; } } return array_values( array_unique( array_slice( $out, 0, 150 ) ) ); } /** * Undo. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function undo( $req ) { $res = History::undo( absint( $req->get_param( 'history_id' ) ) ); if ( is_wp_error( $res ) ) { return new \WP_Error( $res->get_error_code(), $res->get_error_message(), array( 'status' => 400 ) ); } return $res; } /** * Export CSV download. * * @param \WP_REST_Request $req Request. * @return \WP_REST_Response|\WP_Error */ public function export( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $params = $req->get_params(); if ( isset( $params['columns'] ) && is_string( $params['columns'] ) ) { $params['columns'] = array_filter( array_map( 'trim', explode( ',', $params['columns'] ) ) ); } $csv = Export::to_csv( $type, $params ); $response = new \WP_REST_Response( $csv, 200 ); $response->header( 'Content-Type', 'text/csv; charset=utf-8' ); $response->header( 'Content-Disposition', 'attachment; filename="sheet-' . $type . '.csv"' ); return $response; } /** * Import CSV. * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function import( $req ) { $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $check = $this->check_type( $type ); if ( is_wp_error( $check ) ) { return $check; } $files = $req->get_file_params(); $text = (string) $req->get_param( 'csv' ); if ( isset( $files['file']['tmp_name'] ) && is_uploaded_file( $files['file']['tmp_name'] ) ) { $text = file_get_contents( $files['file']['tmp_name'] ); } if ( '' === trim( (string) $text ) ) { return new \WP_Error( 'rafgrid_bad_request', __( 'Empty CSV.', 'rafgrid' ), array( 'status' => 400 ) ); } return Export::from_csv( $type, (string) $text ); } /** * Meta keys for filters/custom columns. * * @param \WP_REST_Request $req Request. * @return array */ public function meta_keys( $req ) { global $wpdb; $type = sanitize_key( $req->get_param( 'post_type' ) ?: 'post' ); $cached = get_transient( 'rafgrid_mkeys_' . $type ); if ( is_array( $cached ) ) { return array( 'keys' => $cached ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- No core API lists meta keys per post type; cached in a transient above and below. $keys = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key NOT LIKE %s ORDER BY pm.meta_key ASC LIMIT 200", $type, '\\_%' ) ); $keys = is_array( $keys ) ? $keys : array(); set_transient( 'rafgrid_mkeys_' . $type, $keys, 5 * MINUTE_IN_SECONDS ); return array( 'keys' => $keys ); } /** * Terms for a taxonomy (search). * * @param \WP_REST_Request $req Request. * @return array|\WP_Error */ public function terms( $req ) { $tax = sanitize_key( $req->get_param( 'taxonomy' ) ); if ( ! taxonomy_exists( $tax ) ) { return new \WP_Error( 'rafgrid_bad_tax', __( 'Unknown taxonomy.', 'rafgrid' ), array( 'status' => 400 ) ); } $search = sanitize_text_field( $req->get_param( 'search' ) ?: '' ); $terms = get_terms( array( 'taxonomy' => $tax, 'hide_empty' => false, 'number' => 50, 'search' => $search, ) ); if ( is_wp_error( $terms ) ) { return array( 'terms' => array() ); } $out = array(); foreach ( $terms as $t ) { $out[] = array( 'id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug ); } return array( 'terms' => $out ); } }