mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-12 02:03:55 +00:00
feat: Meta Box custom-fields abilities (metabox-read / metabox-write)
This commit is contained in:
@@ -42,6 +42,7 @@ A WordPress plugin that extends the [WordPress MCP Adapter](https://github.com/W
|
||||
- **138 tools** — with Elementor Pro (99 active)
|
||||
- **152 tools** — with Elementor Pro + Elementor 4.0 (113 active; + WooCommerce adds no new tools)
|
||||
- **+2 ACF tools** when Advanced Custom Fields (free or PRO) is active — `acf-read` and `acf-write`, two dispatchers exposing **15 ACF operations** (8 read, 7 write; the Custom-Post-Type/taxonomy operations need ACF 6.1+). Each dispatcher is a single toggle under Tools → Plugins → ACF (listing the operations it covers); `acf-read` is on by default, `acf-write` ships off (v3.2.1)
|
||||
- **+2 Meta Box tools** when Meta Box (free core) is active — `metabox-read` and `metabox-write`, two dispatchers exposing **4 Meta Box operations** (3 read, 1 write). Each dispatcher is a single toggle under Tools → Plugins → Meta Box (listing the operations it covers); `metabox-read` is on by default, `metabox-write` ships off (v3.4.2)
|
||||
- **39 of these ship disabled-by-default** (SEO & Accessibility, Widget Builder, PHP Snippets, the 9 Plugins & Themes write tools, `delete-media`, the 2 Users write tools, the 3 Filesystem write tools, and the 3 Database write tools) — plus the 7 ACF write operations are off by default within `acf-write` — so the typical active surface is smaller until you opt in on the Tools tab
|
||||
- **WordPress Content (beyond Elementor)** — Create and manage posts, pages, and any custom post type — content, status, taxonomy terms, custom fields, and featured images — via MCP, without touching Elementor data. Built on WP core; every post carries an `is_elementor` flag that steers agents to the Elementor tools for builder pages
|
||||
- **WordPress Settings (beyond Elementor, domain 2)** — Read and batch-update core WordPress settings (general/reading/writing/discussion/media/permalinks) over MCP. Curated allowlist only — no arbitrary option access; `admin_email` is read-only; permalink changes auto-flush rewrite rules. `manage_options`. (v3.0.0)
|
||||
@@ -49,6 +50,7 @@ A WordPress plugin that extends the [WordPress MCP Adapter](https://github.com/W
|
||||
- **Media Library (beyond Elementor, domain 4)** — Fetch full attachment detail (`get-media`: every registered size, dimensions, metadata, alt/caption/description), edit metadata (`update-media`: alt text, title, caption, description — a one-call accessibility/SEO fix), and delete attachments (`delete-media`: destructive and effectively permanent; disabled-by-default and requires `confirm:true`). URL uploads continue via `sideload-image`. (v3.0.0)
|
||||
- **Users (beyond Elementor, domain 5)** — List and read WordPress users, and safely create/edit non-admin profiles over MCP. Hard guardrails: no delete-user and no role-change tool; `create-user` assigns only non-admin roles and auto-generates a strong password (emailed to the new user — never returned); `update-user` edits profile fields only and refuses any user with admin-level capabilities (administrators are off-limits). `list-users`/`get-user` are enabled by default; `create-user`/`update-user` are disabled-by-default. (v3.0.0)
|
||||
- **ACF / ACF PRO (beyond Elementor)** — Read and write Advanced Custom Fields values on posts and options pages, discover and author field groups, and register ACF-managed Custom Post Types and taxonomies (ACF 6.1+) — enough to stand up a full content structure end-to-end (CPT → taxonomy → field group → posts with values). Full Pro field support: repeaters, flexible content (layout-validated), galleries, groups, and clones round-trip as nested JSON. Only registers when ACF is active; writes resolve field names to keys, ship disabled-by-default, and there is deliberately no delete and no slug/field rename (renames orphan stored content). (v3.2.1)
|
||||
- **Meta Box** — Read/write Meta Box custom-field values and discover field groups. Only registers when Meta Box is active; `metabox-read` is on by default, `metabox-write` ships disabled-by-default (values only — no delete, and unknown fields are skipped rather than created). (v3.4.2)
|
||||
- **Performance & Security (beyond Elementor, domain 6)** — Two read-only diagnostics. **`analyze-performance`** audits server config, WordPress internals (database size, autoloaded options, post revisions, cron backlog, persistent object cache, OPcache, plugin count), and a target page (defaults to the frontpage; optional `url`/`post_id`) for performance bottlenecks. **`scan-security`** — the security counterpart — scans across malware heuristics (eval/base64 obfuscation, request-driven execution, command execution, webshells, encoded blobs, executable PHP under uploads), WordPress core-file integrity (against the official wordpress.org checksums), hardening (file editing, debug output, `admin` username, XML-RPC, version disclosure, HTTPS, security headers), and outdated/abandoned software. Both return a scored report (0-100 + A–F grade) with severity-tagged findings and ranked recommendations; the malware walk is bounded and never returns full file contents (path:line + snippet only). Read-only, self-contained (no external API beyond optional, graceful wordpress.org calls), enabled by default. (v3.0.0)
|
||||
- **Filesystem (beyond Elementor)** — Read and scan any file inside the WordPress install (`read-file`, `list-directory`, `search-files` — enabled by default). Modify and delete files (`write-file`, `edit-file`, `delete-file`) are disabled-by-default: every path is confined to ABSPATH (no traversal/symlink escape), writes auto-back up the original, `wp-config.php`/`.htaccess` are refused, the `edit_files` capability is enforced (honoring `DISALLOW_FILE_EDIT`), and all writes are audit-logged. `delete-file` requires `confirm:true`. `manage_options`. (v3.0.0)
|
||||
- **Database (beyond Elementor)** — Inspect the database with flexible read-only SQL (`list-tables`, `describe-table`, `query` — SELECT/SHOW/DESCRIBE/EXPLAIN only; writes, DDL, stacked statements, MySQL `/*!` executable comments, and file-access SQL are rejected; results capped; enabled by default). Structured parameterized writes (`insert-row`, `update-rows`, `delete-rows`) are disabled-by-default: uses `$wpdb` throughout (no raw write-SQL/DDL), forces a non-empty WHERE on update/delete, refuses `wp_users`/`wp_usermeta`, captures a before-image snapshot to an audit log, and `delete-rows` requires `confirm:true`. `manage_options`. (v3.0.0)
|
||||
@@ -370,6 +372,15 @@ Read and write ACF field values, discover and author field groups, and register
|
||||
| `create-acf-taxonomy` | Register a new taxonomy through ACF; requires `object_type` (post type slugs); slug validated (≤ 32 chars); labels auto-generated (`manage_options`; disabled-by-default) |
|
||||
| `update-acf-taxonomy` | Edit an ACF-managed taxonomy (labels, hierarchy, attached post types); slug is immutable (`manage_options`; disabled-by-default) |
|
||||
|
||||
### Meta Box — beyond Elementor (2 dispatcher tools, v3.4.2)
|
||||
|
||||
Read and write [Meta Box](https://metabox.io/) custom-field values and discover its field groups over MCP. Only registers when Meta Box is active.
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `metabox-read` | Read Meta Box field groups, field definitions, and values (posts + object_type passthrough) |
|
||||
| `metabox-write` | Write Meta Box field values (disabled by default) |
|
||||
|
||||
### WordPress Performance & Security — beyond Elementor, domain 6 (2 tools, v3.0.0)
|
||||
|
||||
Two read-only diagnostics over MCP, both self-contained (the only off-site calls are to wordpress.org — optional and graceful offline). Both return a scored report (0-100 + A–F grade) with severity-tagged findings and ranked recommendations. `manage_options`; enabled by default.
|
||||
|
||||
@@ -194,6 +194,13 @@ class EMCP_Tools_Ability_Registrar {
|
||||
$this->ability_names = array_merge( $this->ability_names, $acf->get_ability_names() );
|
||||
}
|
||||
|
||||
// Meta Box abilities — only when Meta Box (free or extensions) is active.
|
||||
if ( class_exists( 'EMCP_Tools_Meta_Box_Abilities' ) && EMCP_Tools_Meta_Box_Abilities::metabox_active() ) {
|
||||
$metabox = new EMCP_Tools_Meta_Box_Abilities();
|
||||
$metabox->register();
|
||||
$this->ability_names = array_merge( $this->ability_names, $metabox->get_ability_names() );
|
||||
}
|
||||
|
||||
// Themes-tab integrations — the framework-agnostic active-theme pack always,
|
||||
// per-framework packs only when that framework is the active theme.
|
||||
$theme_integrations = array();
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
<?php
|
||||
/**
|
||||
* Meta Box (metabox.io) custom-fields MCP abilities.
|
||||
*
|
||||
* Two dispatcher tools — metabox-read / metabox-write — for reading and writing
|
||||
* Meta Box field VALUES on posts (and, via an object_type passthrough, any object
|
||||
* type a Meta Box extension registers), plus schema discovery of registered meta
|
||||
* boxes. The whole group only registers when Meta Box free core is active.
|
||||
*
|
||||
* Meta Box free core has NO field builder (fields are declared in PHP via the
|
||||
* rwmb_meta_boxes filter), so — unlike the ACF integration — this exposes NO
|
||||
* field-group / CPT / taxonomy authoring and NO delete op. Values are read and
|
||||
* written; field definitions are only ever read. Note Meta Box's field naming:
|
||||
* a field's `id` is the meta key and `name` is the human label.
|
||||
*
|
||||
* @package EMCP_Tools
|
||||
* @since 3.4.2
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and implements the Meta Box abilities.
|
||||
*
|
||||
* @since 3.4.2
|
||||
*/
|
||||
class EMCP_Tools_Meta_Box_Abilities {
|
||||
|
||||
/** Max recursion depth for value/field normalization. @since 3.4.2 */
|
||||
const MAX_DEPTH = 10;
|
||||
|
||||
/** @since 3.4.2 @var string[] */
|
||||
private $ability_names = array();
|
||||
|
||||
/** @since 3.4.2 @return string[] */
|
||||
public function get_ability_names(): array {
|
||||
return $this->ability_names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Meta Box free core is active and exposes its registry API.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @return bool
|
||||
*/
|
||||
public static function metabox_active(): bool {
|
||||
return defined( 'RWMB_VER' ) && function_exists( 'rwmb_get_registry' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Meta Box domain as two dispatcher tools.
|
||||
*
|
||||
* @since 3.4.2
|
||||
*/
|
||||
public function register(): void {
|
||||
$this->register_read_dispatcher();
|
||||
$this->register_write_dispatcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation map behind the two dispatchers.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function operations(): array {
|
||||
return array(
|
||||
'list-field-groups' => array( 'mode' => 'read', 'run' => 'execute_list_field_groups', 'perm' => 'check_read_permission', 'desc' => __( 'List registered Meta Box field groups (id, title, object type, post types, field count). arguments: { search?, object_type? }.', 'emcp-tools' ) ),
|
||||
'get-field-group' => array( 'mode' => 'read', 'run' => 'execute_get_field_group', 'perm' => 'check_read_permission', 'desc' => __( 'Get one Meta Box field group and its fields (id, label, type, settings). arguments: { id }.', 'emcp-tools' ) ),
|
||||
'get-fields' => array( 'mode' => 'read', 'run' => 'execute_get_fields', 'perm' => 'check_fields_permission', 'desc' => __( 'Read Meta Box field values from a post or object. arguments: { post_id } or { object_type, object_id }; optional { fields, include_field_settings }.', 'emcp-tools' ) ),
|
||||
'update-fields' => array( 'mode' => 'write', 'run' => 'execute_update_fields', 'perm' => 'check_fields_permission', 'desc' => __( 'Write Meta Box field values on a post or object. arguments: { post_id | object_type + object_id, fields: { id: value } }.', 'emcp-tools' ) ),
|
||||
);
|
||||
}
|
||||
|
||||
private function register_read_dispatcher(): void {
|
||||
$this->ability_names[] = 'emcp-tools/metabox-read';
|
||||
emcp_tools_register_ability(
|
||||
'emcp-tools/metabox-read',
|
||||
array(
|
||||
'label' => __( 'Meta Box Read', 'emcp-tools' ),
|
||||
'description' => __( 'Read Meta Box (metabox.io) custom-field data: registered field groups, their field definitions, and field values on a post or object. Call with no "operation" to list the available read operations and their arguments, then call again with { operation, arguments }.', 'emcp-tools' ),
|
||||
'category' => 'emcp-tools',
|
||||
'execute_callback' => array( $this, 'run_metabox_read' ),
|
||||
'permission_callback' => array( $this, 'check_read_permission' ),
|
||||
'input_schema' => array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'operation' => array( 'type' => 'string', 'description' => __( 'The read operation to run. Omit to list operations. One of: list-field-groups, get-field-group, get-fields.', 'emcp-tools' ) ),
|
||||
'arguments' => array( 'type' => 'object', 'description' => __( 'Arguments for the chosen operation (see the catalog returned when operation is omitted).', 'emcp-tools' ) ),
|
||||
),
|
||||
),
|
||||
'meta' => array(
|
||||
'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ),
|
||||
'show_in_rest' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function register_write_dispatcher(): void {
|
||||
$this->ability_names[] = 'emcp-tools/metabox-write';
|
||||
emcp_tools_register_ability(
|
||||
'emcp-tools/metabox-write',
|
||||
array(
|
||||
'label' => __( 'Meta Box Write', 'emcp-tools' ),
|
||||
'description' => __( 'Write Meta Box (metabox.io) custom-field values. Disabled by default — enable under EMCP Tools → Tools → Plugins → Meta Box. Call with no "operation" to list the available write operations and their arguments, then call again with { operation, arguments }.', 'emcp-tools' ),
|
||||
'category' => 'emcp-tools',
|
||||
'execute_callback' => array( $this, 'run_metabox_write' ),
|
||||
'permission_callback' => array( $this, 'check_read_permission' ),
|
||||
'input_schema' => array(
|
||||
'type' => 'object',
|
||||
'properties' => array(
|
||||
'operation' => array( 'type' => 'string', 'description' => __( 'The write operation to run. Omit to list operations. One of: update-fields.', 'emcp-tools' ) ),
|
||||
'arguments' => array( 'type' => 'object', 'description' => __( 'Arguments for the chosen operation (see the catalog returned when operation is omitted).', 'emcp-tools' ) ),
|
||||
),
|
||||
),
|
||||
'meta' => array(
|
||||
'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ),
|
||||
'show_in_rest' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** @since 3.4.2 */
|
||||
public function run_metabox_read( $input ) {
|
||||
return $this->dispatch( 'read', $input );
|
||||
}
|
||||
|
||||
/** @since 3.4.2 */
|
||||
public function run_metabox_write( $input ) {
|
||||
return $this->dispatch( 'write', $input );
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an operation to its executor after gating.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param string $mode 'read' or 'write'.
|
||||
* @param mixed $input Dispatcher input ({ operation, arguments }).
|
||||
* @return mixed
|
||||
*/
|
||||
private function dispatch( string $mode, $input ) {
|
||||
$operation = isset( $input['operation'] ) ? str_replace( '_', '-', sanitize_key( (string) $input['operation'] ) ) : '';
|
||||
if ( '' === $operation ) {
|
||||
return $this->operations_catalog( $mode );
|
||||
}
|
||||
$ops = $this->operations();
|
||||
if ( ! isset( $ops[ $operation ] ) || $ops[ $operation ]['mode'] !== $mode ) {
|
||||
return new \WP_Error(
|
||||
'unknown_operation',
|
||||
sprintf(
|
||||
/* translators: 1: mode (read/write), 2: operation name */
|
||||
__( 'Unknown Meta Box %1$s operation "%2$s". Call metabox-%1$s with no operation to list the available operations.', 'emcp-tools' ),
|
||||
$mode,
|
||||
$operation
|
||||
)
|
||||
);
|
||||
}
|
||||
$op = $ops[ $operation ];
|
||||
$args = ( isset( $input['arguments'] ) && is_array( $input['arguments'] ) ) ? $input['arguments'] : array();
|
||||
$perm = $op['perm'];
|
||||
if ( ! $this->$perm( $args ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You do not have permission to perform this Meta Box operation.', 'emcp-tools' ) );
|
||||
}
|
||||
$run = $op['run'];
|
||||
return $this->$run( $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the discovery catalog for a mode.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param string $mode 'read' or 'write'.
|
||||
* @return array
|
||||
*/
|
||||
private function operations_catalog( string $mode ): array {
|
||||
$list = array();
|
||||
foreach ( $this->operations() as $name => $op ) {
|
||||
if ( $op['mode'] !== $mode ) {
|
||||
continue;
|
||||
}
|
||||
$list[] = array( 'operation' => $name, 'description' => $op['desc'] );
|
||||
}
|
||||
return array(
|
||||
'mode' => $mode,
|
||||
'operations' => $list,
|
||||
'usage' => sprintf(
|
||||
/* translators: %s: mode (read/write) */
|
||||
__( 'Call metabox-%s again with { "operation": "<name>", "arguments": { ... } }.', 'emcp-tools' ),
|
||||
$mode
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// list-field-groups
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @since 3.4.2
|
||||
* @param array $input
|
||||
* @return array
|
||||
*/
|
||||
public function execute_list_field_groups( $input ): array {
|
||||
$search = isset( $input['search'] ) ? strtolower( sanitize_text_field( (string) $input['search'] ) ) : '';
|
||||
$ot_filter = isset( $input['object_type'] ) ? sanitize_key( (string) $input['object_type'] ) : '';
|
||||
|
||||
$rows = array();
|
||||
foreach ( $this->all_meta_boxes() as $mb ) {
|
||||
$object_type = method_exists( $mb, 'get_object_type' ) ? (string) $mb->get_object_type() : 'post';
|
||||
if ( '' !== $ot_filter && $object_type !== $ot_filter ) {
|
||||
continue;
|
||||
}
|
||||
$id = (string) ( $mb->id ?? '' );
|
||||
$title = (string) ( $mb->title ?? '' );
|
||||
if ( '' !== $search && false === strpos( strtolower( $title . ' ' . $id ), $search ) ) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = array(
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'object_type' => $object_type,
|
||||
'post_types' => array_values( (array) ( $mb->post_types ?? array() ) ),
|
||||
'field_count' => count( (array) ( $mb->fields ?? array() ) ),
|
||||
);
|
||||
}
|
||||
|
||||
return array( 'field_groups' => $rows, 'total' => count( $rows ) );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// get-field-group
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @since 3.4.2
|
||||
* @param array $input
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function execute_get_field_group( $input ) {
|
||||
$id = sanitize_text_field( (string) ( $input['id'] ?? '' ) );
|
||||
if ( '' === $id ) {
|
||||
return new \WP_Error( 'missing_params', __( 'A field group "id" is required.', 'emcp-tools' ) );
|
||||
}
|
||||
$found = null;
|
||||
foreach ( $this->all_meta_boxes() as $mb ) {
|
||||
if ( (string) ( $mb->id ?? '' ) === $id ) {
|
||||
$found = $mb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( ! $found ) {
|
||||
return new \WP_Error( 'group_not_found', __( 'Field group not found.', 'emcp-tools' ) );
|
||||
}
|
||||
$object_type = method_exists( $found, 'get_object_type' ) ? (string) $found->get_object_type() : 'post';
|
||||
return array(
|
||||
'id' => $id,
|
||||
'title' => (string) ( $found->title ?? '' ),
|
||||
'object_type' => $object_type,
|
||||
'post_types' => array_values( (array) ( $found->post_types ?? array() ) ),
|
||||
'fields' => array_map( array( $this, 'format_field' ), array_values( (array) ( $found->fields ?? array() ) ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact view of one Meta Box field definition (nested `fields` included).
|
||||
* Meta Box stores the meta key in `id` and the human label in `name`.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param mixed $field Field config array.
|
||||
* @param int $depth Recursion guard.
|
||||
* @return array
|
||||
*/
|
||||
private function format_field( $field, int $depth = 0 ): array {
|
||||
$field = (array) $field;
|
||||
$out = array(
|
||||
'id' => (string) ( $field['id'] ?? '' ),
|
||||
'label' => (string) ( $field['name'] ?? '' ),
|
||||
'type' => (string) ( $field['type'] ?? '' ),
|
||||
);
|
||||
foreach ( array( 'desc', 'std', 'multiple', 'clone', 'required', 'options', 'min', 'max', 'post_type', 'taxonomy' ) as $setting ) {
|
||||
if ( isset( $field[ $setting ] ) && '' !== $field[ $setting ] && array() !== $field[ $setting ] ) {
|
||||
$out[ $setting ] = $field[ $setting ];
|
||||
}
|
||||
}
|
||||
if ( $depth < self::MAX_DEPTH && ! empty( $field['fields'] ) && is_array( $field['fields'] ) ) {
|
||||
$out['fields'] = array();
|
||||
foreach ( $field['fields'] as $sub ) {
|
||||
$out['fields'][] = $this->format_field( $sub, $depth + 1 );
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* All registered meta boxes (empty when Meta Box is inactive).
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @return array
|
||||
*/
|
||||
private function all_meta_boxes(): array {
|
||||
if ( ! function_exists( 'rwmb_get_registry' ) ) {
|
||||
return array();
|
||||
}
|
||||
$registry = rwmb_get_registry( 'meta_box' );
|
||||
return method_exists( $registry, 'all' ) ? (array) $registry->all() : array();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// get-fields
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @since 3.4.2
|
||||
* @param array $input
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function execute_get_fields( $input ) {
|
||||
$target = $this->resolve_target( $input );
|
||||
if ( is_wp_error( $target ) ) {
|
||||
return $target;
|
||||
}
|
||||
$fields = $this->applicable_fields( $target['object_type'], $target['object_id'] );
|
||||
$only = array();
|
||||
if ( ! empty( $input['fields'] ) && is_array( $input['fields'] ) ) {
|
||||
$only = array_map( 'strval', $input['fields'] );
|
||||
}
|
||||
$with_settings = ! empty( $input['include_field_settings'] );
|
||||
|
||||
$out = array();
|
||||
foreach ( $fields as $id => $field ) {
|
||||
if ( $only && ! in_array( (string) $id, $only, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$value = function_exists( 'rwmb_meta' )
|
||||
? rwmb_meta( $id, array( 'object_type' => $target['object_type'] ), $target['object_id'] )
|
||||
: null;
|
||||
$value = $this->normalize_value( $value );
|
||||
if ( $with_settings ) {
|
||||
$out[ $id ] = array(
|
||||
'type' => (string) ( $field['type'] ?? '' ),
|
||||
'label' => (string) ( $field['name'] ?? '' ),
|
||||
'value' => $value,
|
||||
);
|
||||
} else {
|
||||
$out[ $id ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array( 'target' => $target, 'fields' => $out );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target: exactly one of { post_id } or { object_type, object_id }.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param array $input
|
||||
* @return array|\WP_Error {object_type, object_id}
|
||||
*/
|
||||
private function resolve_target( $input ) {
|
||||
$post_id = absint( $input['post_id'] ?? 0 );
|
||||
$object_type = isset( $input['object_type'] ) ? sanitize_key( (string) $input['object_type'] ) : '';
|
||||
$object_id = $input['object_id'] ?? null;
|
||||
|
||||
if ( $post_id && '' !== $object_type ) {
|
||||
return new \WP_Error( 'invalid_target', __( 'Pass either "post_id" or "object_type"+"object_id", not both.', 'emcp-tools' ) );
|
||||
}
|
||||
// Normalize { object_type:'post', object_id:N } onto the post_id path so
|
||||
// it gets the same 404 check and edit_post gating as { post_id:N }.
|
||||
if ( ! $post_id && 'post' === $object_type && null !== $object_id && '' !== $object_id ) {
|
||||
$post_id = absint( $object_id );
|
||||
$object_type = '';
|
||||
}
|
||||
if ( $post_id ) {
|
||||
if ( ! get_post( $post_id ) ) {
|
||||
return new \WP_Error( 'post_not_found', __( 'Post not found.', 'emcp-tools' ) );
|
||||
}
|
||||
return array( 'object_type' => 'post', 'object_id' => $post_id );
|
||||
}
|
||||
if ( '' !== $object_type ) {
|
||||
if ( null === $object_id || '' === $object_id ) {
|
||||
return new \WP_Error( 'invalid_target', __( 'An "object_id" is required with "object_type".', 'emcp-tools' ) );
|
||||
}
|
||||
$object_id = is_numeric( $object_id ) ? absint( $object_id ) : sanitize_text_field( (string) $object_id );
|
||||
return array( 'object_type' => $object_type, 'object_id' => $object_id );
|
||||
}
|
||||
return new \WP_Error( 'invalid_target', __( 'A target is required: pass "post_id" or "object_type"+"object_id".', 'emcp-tools' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields applicable to a target, keyed by field id. For posts, only meta
|
||||
* boxes whose post_types include the post's type are considered.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param string $object_type
|
||||
* @param int|string $object_id
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private function applicable_fields( string $object_type, $object_id ): array {
|
||||
$out = array();
|
||||
if ( ! function_exists( 'rwmb_get_registry' ) ) {
|
||||
return $out;
|
||||
}
|
||||
$registry = rwmb_get_registry( 'meta_box' );
|
||||
$boxes = method_exists( $registry, 'get_by' )
|
||||
? (array) $registry->get_by( array( 'object_type' => $object_type ) )
|
||||
: ( method_exists( $registry, 'all' ) ? (array) $registry->all() : array() );
|
||||
|
||||
$post_type = '';
|
||||
if ( 'post' === $object_type && is_numeric( $object_id ) ) {
|
||||
$post = get_post( (int) $object_id );
|
||||
$post_type = $post ? (string) $post->post_type : '';
|
||||
}
|
||||
|
||||
foreach ( $boxes as $mb ) {
|
||||
if ( method_exists( $mb, 'get_object_type' ) && (string) $mb->get_object_type() !== $object_type ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'post' === $object_type && '' !== $post_type ) {
|
||||
$pts = (array) ( $mb->post_types ?? array() );
|
||||
if ( $pts && ! in_array( $post_type, $pts, true ) ) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
foreach ( (array) ( $mb->fields ?? array() ) as $field ) {
|
||||
$field = (array) $field;
|
||||
$id = (string) ( $field['id'] ?? '' );
|
||||
if ( '' === $id || isset( $out[ $id ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$out[ $id ] = $field;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a Meta Box value JSON-friendly and compact: WP objects and MB
|
||||
* image/file arrays become small summaries; nesting is depth-capped.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param mixed $value
|
||||
* @param int $depth
|
||||
* @return mixed
|
||||
*/
|
||||
private function normalize_value( $value, int $depth = 0 ) {
|
||||
if ( $depth >= self::MAX_DEPTH ) {
|
||||
return is_scalar( $value ) || null === $value ? $value : '[max depth reached]';
|
||||
}
|
||||
if ( $value instanceof \WP_Post ) {
|
||||
return array( 'id' => (int) $value->ID, 'title' => (string) $value->post_title, 'post_type' => (string) $value->post_type );
|
||||
}
|
||||
if ( class_exists( '\WP_Term' ) && $value instanceof \WP_Term ) {
|
||||
return array( 'id' => (int) $value->term_id, 'name' => (string) $value->name, 'taxonomy' => (string) $value->taxonomy );
|
||||
}
|
||||
if ( class_exists( '\WP_User' ) && $value instanceof \WP_User ) {
|
||||
return array( 'id' => (int) $value->ID, 'display_name' => (string) $value->display_name );
|
||||
}
|
||||
if ( is_object( $value ) ) {
|
||||
$value = get_object_vars( $value );
|
||||
}
|
||||
if ( is_array( $value ) ) {
|
||||
// Meta Box single image/file value: assoc array with ID + url (+ path/full_url/mime).
|
||||
if ( isset( $value['ID'], $value['url'] ) && ( isset( $value['full_url'] ) || isset( $value['path'] ) || isset( $value['mime_type'] ) ) ) {
|
||||
return array(
|
||||
'id' => (int) $value['ID'],
|
||||
'url' => (string) $value['url'],
|
||||
'alt' => (string) ( $value['alt'] ?? '' ),
|
||||
'title' => (string) ( $value['title'] ?? '' ),
|
||||
);
|
||||
}
|
||||
$out = array();
|
||||
foreach ( $value as $k => $v ) {
|
||||
$out[ $k ] = $this->normalize_value( $v, $depth + 1 );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// update-fields
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @since 3.4.2
|
||||
* @param array $input
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function execute_update_fields( $input ) {
|
||||
$target = $this->resolve_target( $input );
|
||||
if ( is_wp_error( $target ) ) {
|
||||
return $target;
|
||||
}
|
||||
$fields = $input['fields'] ?? null;
|
||||
if ( ! is_array( $fields ) || array() === $fields ) {
|
||||
return new \WP_Error( 'missing_params', __( 'A non-empty "fields" map is required.', 'emcp-tools' ) );
|
||||
}
|
||||
|
||||
$known = $this->applicable_fields( $target['object_type'], $target['object_id'] );
|
||||
$updated = array();
|
||||
$skipped = array();
|
||||
$values = array();
|
||||
|
||||
foreach ( $fields as $id => $value ) {
|
||||
$id = (string) $id;
|
||||
if ( ! isset( $known[ $id ] ) ) {
|
||||
$skipped[] = array( 'field' => $id, 'reason' => 'field_not_found' );
|
||||
continue;
|
||||
}
|
||||
if ( function_exists( 'rwmb_set_meta' ) ) {
|
||||
rwmb_set_meta( $target['object_id'], $id, $value, array( 'object_type' => $target['object_type'] ) );
|
||||
}
|
||||
// rwmb_set_meta() returns void, so confirm by re-reading.
|
||||
$updated[] = $id;
|
||||
$values[ $id ] = $this->normalize_value(
|
||||
function_exists( 'rwmb_meta' ) ? rwmb_meta( $id, array( 'object_type' => $target['object_type'] ), $target['object_id'] ) : null
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'target' => $target,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'values' => $values,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permission callbacks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** @since 3.4.2 @return bool */
|
||||
public function check_read_permission(): bool {
|
||||
return current_user_can( 'edit_posts' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-value permission: edit_post on a target post; manage_options for
|
||||
* non-post object types (site-wide data).
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @param array|null $input
|
||||
* @return bool
|
||||
*/
|
||||
public function check_fields_permission( $input = null ): bool {
|
||||
$object_type = isset( $input['object_type'] ) ? sanitize_key( (string) $input['object_type'] ) : '';
|
||||
if ( '' !== $object_type && 'post' !== $object_type ) {
|
||||
return current_user_can( 'manage_options' );
|
||||
}
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
return false;
|
||||
}
|
||||
// A post target can arrive as either post_id or object_type=post+object_id;
|
||||
// gate edit_post on whichever one actually names the target post.
|
||||
$post_id = absint( $input['post_id'] ?? 0 );
|
||||
if ( ! $post_id && 'post' === $object_type ) {
|
||||
$post_id = absint( $input['object_id'] ?? 0 );
|
||||
}
|
||||
return ! $post_id || current_user_can( 'edit_post', $post_id );
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ class EMCP_Tools_Admin {
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
const DEFAULTS_VERSION = 18;
|
||||
const DEFAULTS_VERSION = 19;
|
||||
|
||||
/**
|
||||
* SEO/A11y Pro MCP tool slugs that ship disabled-by-default (v2 defaults).
|
||||
@@ -538,6 +538,22 @@ class EMCP_Tools_Admin {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Meta Box dispatcher tool slugs. The domain registers as two dispatcher
|
||||
* tools (metabox-read enabled by default, metabox-write disabled by default);
|
||||
* the operations live behind them. Both slugs are excluded from the drift
|
||||
* guard since the domain only registers when Meta Box is active.
|
||||
*
|
||||
* @since 3.4.2
|
||||
* @return string[]
|
||||
*/
|
||||
public static function metabox_tool_slugs(): array {
|
||||
return array(
|
||||
'emcp-tools/metabox-read',
|
||||
'emcp-tools/metabox-write',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-release per-operation ACF slugs (the earlier 15-tool layout).
|
||||
* Kept only so the defaults step can strip them from the stored option on
|
||||
@@ -710,6 +726,13 @@ class EMCP_Tools_Admin {
|
||||
$add = array_merge( $add, EMCP_Tools_WPCLI_Abilities::slugs() );
|
||||
}
|
||||
|
||||
// v19 — Meta Box is exposed as two dispatcher tools (metabox-read /
|
||||
// metabox-write). The write dispatcher ships disabled-by-default; the
|
||||
// read dispatcher stays on.
|
||||
if ( $applied < 19 ) {
|
||||
$add[] = 'emcp-tools/metabox-write';
|
||||
}
|
||||
|
||||
$merged = array_values( array_unique( array_merge( $existing, $add ) ) );
|
||||
update_option( self::OPTION_DISABLED_TOOLS, $merged );
|
||||
update_option( self::OPTION_DEFAULTS_APPLIED, (string) self::DEFAULTS_VERSION );
|
||||
@@ -2069,6 +2092,7 @@ class EMCP_Tools_Admin {
|
||||
$emcp_conditional = array_merge(
|
||||
self::themer_php_tool_slugs(),
|
||||
self::acf_tool_slugs(),
|
||||
self::metabox_tool_slugs(),
|
||||
self::theme_tool_slugs(),
|
||||
array( 'emcp-tools/resize-media' )
|
||||
);
|
||||
@@ -2493,6 +2517,31 @@ class EMCP_Tools_Admin {
|
||||
),
|
||||
),
|
||||
),
|
||||
'wp_metabox' => array(
|
||||
'platform' => 'plugins',
|
||||
'label' => __( 'Meta Box', 'emcp-tools' ),
|
||||
'note' => __( 'Plugin integrations are exposed as two tools — one Read, one Write. The AI calls a tool with an operation name; each tool bundles the operations listed on its card. Toggle a tool to allow or block all of its operations at once.', 'emcp-tools' ),
|
||||
'tools' => array(
|
||||
'emcp-tools/metabox-read' => array(
|
||||
'label' => __( 'Meta Box Read', 'emcp-tools' ),
|
||||
'description' => __( 'Read Meta Box data — field groups, field definitions, and field values for posts and other supported object types.', 'emcp-tools' ),
|
||||
'badges' => array( 'read-only' ),
|
||||
'operations' => array(
|
||||
'list-field-groups',
|
||||
'get-field-group',
|
||||
'get-fields',
|
||||
),
|
||||
),
|
||||
'emcp-tools/metabox-write' => array(
|
||||
'label' => __( 'Meta Box Write', 'emcp-tools' ),
|
||||
'description' => __( 'Write Meta Box field values. No delete operations; unknown fields are skipped, not created.', 'emcp-tools' ),
|
||||
'badges' => array(),
|
||||
'operations' => array(
|
||||
'update-fields',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
'theme_active' => array(
|
||||
'platform' => 'themes',
|
||||
'label' => __( 'Active Theme', 'emcp-tools' ),
|
||||
|
||||
@@ -137,6 +137,8 @@ class EMCP_Tools_Bootstrap {
|
||||
add_action( 'init', array( 'EMCP_Tools_Nav_Menu_Shortcode', 'register' ) );
|
||||
// ACF tools (field values + field group discovery/authoring; writes off by default).
|
||||
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-acf-abilities.php';
|
||||
// Meta Box tools (field values + field group discovery; writes off by default).
|
||||
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-metabox-abilities.php';
|
||||
|
||||
// Themes domain: the child-theme builder + the dispatcher base (must load
|
||||
// before its subclasses) + the integrations.
|
||||
|
||||
@@ -25,6 +25,7 @@ As of v3.0.0 the 62 per-widget tools were folded into a catalog-backed model, so
|
||||
* **Media Library (beyond Elementor)**: Fetch full attachment detail, edit metadata (alt text, title, caption, description), and delete attachments over MCP. get/update are enabled by default; delete is disabled-by-default and requires explicit confirmation. (v3.0.0)
|
||||
* **Users (beyond Elementor)**: List and read WordPress users, and safely create/edit non-admin profiles over MCP. No delete and no role changes; new users get an auto-generated password by email (never returned); administrators are off-limits to editing. Reads enabled by default; create/update disabled-by-default. (v3.0.0)
|
||||
* **ACF / ACF PRO (beyond Elementor)**: Read and write Advanced Custom Fields values on posts and options pages, discover and author field groups, and register ACF-managed Custom Post Types and taxonomies (ACF 6.1+), enough to build a full content structure end-to-end. Full Pro field support (repeater, flexible content, gallery, group, clone) as nested JSON. Only registers when ACF is active; the 7 write tools ship disabled-by-default; no deletes and no slug/field renames. (v3.2.1)
|
||||
* **Meta Box (beyond Elementor)**: Read/write Meta Box custom-field values and discover field groups over MCP. Only registers when Meta Box is active; `metabox-read` is on by default, `metabox-write` ships disabled-by-default (values only, no delete; unknown fields are skipped rather than created). (v3.4.2)
|
||||
* **Performance Analyzer (beyond Elementor)**: Scan the server config, WordPress internals (database size, autoloaded options, post revisions, cron backlog, object cache, OPcache, plugin count), and a target page (defaults to the frontpage; optional URL or post) for performance bottlenecks. Returns a scored report (0-100 + A–F grade) with severity-tagged findings and ranked recommendations. Read-only, self-contained (no external API), enabled by default. (v3.0.0)
|
||||
* **Security & Malware Scanner (beyond Elementor)**: The security counterpart to the Performance Analyzer: scan across malware heuristics (eval/base64 obfuscation, request-driven execution, command execution, webshells, encoded blobs, executable PHP under uploads), WordPress core-file integrity (against the official wordpress.org checksums), hardening (file editing, debug output, admin username, XML-RPC, version disclosure, HTTPS, security headers), and outdated/abandoned software. Returns a scored report (0-100 + A–F grade) with severity-tagged findings and ranked recommendations. The malware walk is bounded and never returns full file contents (path:line + snippet only). Read-only, self-contained (only wordpress.org calls, graceful offline), enabled by default. (v3.0.0)
|
||||
* **Filesystem (beyond Elementor)**: read/scan any file in the WordPress install; modify/delete off by default. (v3.0.0)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MetaBoxAbilitiesTest extends TestCase {
|
||||
private EMCP_Tools_Meta_Box_Abilities $mb;
|
||||
|
||||
protected function setUp(): void {
|
||||
emcp_test_reset();
|
||||
$GLOBALS['emcp_test']['caps'] = array( 'edit_posts', 'manage_options' );
|
||||
$this->mb = new EMCP_Tools_Meta_Box_Abilities();
|
||||
}
|
||||
|
||||
public function test_metabox_active_true_when_rwmb_ver_defined(): void {
|
||||
$this->assertTrue( EMCP_Tools_Meta_Box_Abilities::metabox_active() );
|
||||
}
|
||||
|
||||
public function test_register_adds_two_dispatchers(): void {
|
||||
$this->mb->register();
|
||||
$names = $this->mb->get_ability_names();
|
||||
$this->assertContains( 'emcp-tools/metabox-read', $names );
|
||||
$this->assertContains( 'emcp-tools/metabox-write', $names );
|
||||
}
|
||||
|
||||
public function test_read_dispatcher_no_operation_returns_catalog(): void {
|
||||
$out = $this->mb->run_metabox_read( array() );
|
||||
$this->assertSame( 'read', $out['mode'] );
|
||||
$ops = array_column( $out['operations'], 'operation' );
|
||||
$this->assertEqualsCanonicalizing(
|
||||
array( 'list-field-groups', 'get-field-group', 'get-fields' ), $ops
|
||||
);
|
||||
}
|
||||
|
||||
public function test_write_dispatcher_no_operation_returns_catalog(): void {
|
||||
$out = $this->mb->run_metabox_write( array() );
|
||||
$this->assertSame( 'write', $out['mode'] );
|
||||
$this->assertSame( array( 'update-fields' ), array_column( $out['operations'], 'operation' ) );
|
||||
}
|
||||
|
||||
public function test_unknown_operation_returns_wp_error(): void {
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'nope' ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'unknown_operation', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_wrong_mode_operation_rejected(): void {
|
||||
// update-fields is a write op; calling it via the read dispatcher must fail.
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'update-fields' ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'unknown_operation', $out->get_error_code() );
|
||||
}
|
||||
|
||||
private function seed_boxes(): void {
|
||||
$GLOBALS['emcp_test']['metabox']['boxes'] = array(
|
||||
new EMCP_Test_MB( array(
|
||||
'id' => 'event_details',
|
||||
'title' => 'Event Details',
|
||||
'post_types' => array( 'event' ),
|
||||
'fields' => array(
|
||||
array( 'id' => 'event_date', 'name' => 'Event Date', 'type' => 'date' ),
|
||||
array( 'id' => 'venue', 'name' => 'Venue', 'type' => 'text', 'std' => 'TBD' ),
|
||||
array( 'id' => 'lineup', 'name' => 'Lineup', 'type' => 'group', 'fields' => array(
|
||||
array( 'id' => 'act', 'name' => 'Act', 'type' => 'text' ),
|
||||
) ),
|
||||
),
|
||||
), 'post' ),
|
||||
new EMCP_Test_MB( array(
|
||||
'id' => 'seo_meta', 'title' => 'SEO', 'post_types' => array( 'post', 'page' ),
|
||||
'fields' => array( array( 'id' => 'seo_title', 'name' => 'SEO Title', 'type' => 'text' ) ),
|
||||
), 'post' ),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_list_field_groups_returns_rows(): void {
|
||||
$this->seed_boxes();
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'list-field-groups' ) );
|
||||
$this->assertSame( 2, $out['total'] );
|
||||
$ids = array_column( $out['field_groups'], 'id' );
|
||||
$this->assertEqualsCanonicalizing( array( 'event_details', 'seo_meta' ), $ids );
|
||||
$event = array_values( array_filter( $out['field_groups'], fn( $g ) => 'event_details' === $g['id'] ) )[0];
|
||||
$this->assertSame( 'Event Details', $event['title'] );
|
||||
$this->assertSame( 'post', $event['object_type'] );
|
||||
$this->assertSame( array( 'event' ), $event['post_types'] );
|
||||
$this->assertSame( 3, $event['field_count'] );
|
||||
}
|
||||
|
||||
public function test_list_field_groups_search_filter(): void {
|
||||
$this->seed_boxes();
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'list-field-groups', 'arguments' => array( 'search' => 'seo' ) ) );
|
||||
$this->assertSame( 1, $out['total'] );
|
||||
$this->assertSame( 'seo_meta', $out['field_groups'][0]['id'] );
|
||||
}
|
||||
|
||||
public function test_get_field_group_returns_fields_with_nesting(): void {
|
||||
$this->seed_boxes();
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-field-group', 'arguments' => array( 'id' => 'event_details' ) ) );
|
||||
$this->assertSame( 'event_details', $out['id'] );
|
||||
$this->assertCount( 3, $out['fields'] );
|
||||
$venue = $out['fields'][1];
|
||||
$this->assertSame( 'venue', $venue['id'] );
|
||||
$this->assertSame( 'Venue', $venue['label'] ); // MB `name` -> our `label`
|
||||
$this->assertSame( 'text', $venue['type'] );
|
||||
$this->assertSame( 'TBD', $venue['std'] );
|
||||
$group = $out['fields'][2];
|
||||
$this->assertArrayHasKey( 'fields', $group );
|
||||
$this->assertSame( 'act', $group['fields'][0]['id'] );
|
||||
}
|
||||
|
||||
public function test_get_field_group_unknown_id_errors(): void {
|
||||
$this->seed_boxes();
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-field-group', 'arguments' => array( 'id' => 'nope' ) ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'group_not_found', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_reads_values_for_post(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['metabox']['values']['post']['10'] = array(
|
||||
'event_date' => '2026-08-01', 'venue' => 'The Roxy',
|
||||
);
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array( 'post_id' => 10 ) ) );
|
||||
$this->assertSame( array( 'object_type' => 'post', 'object_id' => 10 ), $out['target'] );
|
||||
$this->assertSame( '2026-08-01', $out['fields']['event_date'] );
|
||||
$this->assertSame( 'The Roxy', $out['fields']['venue'] );
|
||||
// seo_meta applies to post/page, not to 'event' -> its field is absent.
|
||||
$this->assertArrayNotHasKey( 'seo_title', $out['fields'] );
|
||||
}
|
||||
|
||||
public function test_get_fields_filter_subset(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['metabox']['values']['post']['10'] = array( 'event_date' => 'x', 'venue' => 'y' );
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array( 'post_id' => 10, 'fields' => array( 'venue' ) ) ) );
|
||||
$this->assertSame( array( 'venue' => 'y' ), $out['fields'] );
|
||||
}
|
||||
|
||||
public function test_get_fields_missing_target_errors(): void {
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array() ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'invalid_target', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_unknown_post_errors(): void {
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array( 'post_id' => 999 ) ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'post_not_found', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_normalizes_image_value(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['metabox']['values']['post']['10'] = array(
|
||||
'venue' => array( 'ID' => 55, 'url' => 'http://x/p.jpg', 'full_url' => 'http://x/p.jpg', 'alt' => 'hall', 'title' => 'Hall', 'width' => 800 ),
|
||||
);
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array( 'post_id' => 10, 'fields' => array( 'venue' ) ) ) );
|
||||
$this->assertSame( array( 'id' => 55, 'url' => 'http://x/p.jpg', 'alt' => 'hall', 'title' => 'Hall' ), $out['fields']['venue'] );
|
||||
}
|
||||
|
||||
public function test_update_fields_writes_and_rereads(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$out = $this->mb->run_metabox_write( array(
|
||||
'operation' => 'update-fields',
|
||||
'arguments' => array( 'post_id' => 10, 'fields' => array( 'venue' => 'Wembley' ) ),
|
||||
) );
|
||||
$this->assertSame( array( 'venue' ), $out['updated'] );
|
||||
$this->assertSame( array(), $out['skipped'] );
|
||||
$this->assertSame( 'Wembley', $out['values']['venue'] );
|
||||
// Confirm it actually persisted to the fixture store.
|
||||
$this->assertSame( 'Wembley', $GLOBALS['emcp_test']['metabox']['values']['post']['10']['venue'] );
|
||||
}
|
||||
|
||||
public function test_update_fields_skips_unknown_field(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$out = $this->mb->run_metabox_write( array(
|
||||
'operation' => 'update-fields',
|
||||
'arguments' => array( 'post_id' => 10, 'fields' => array( 'not_a_field' => 'x' ) ),
|
||||
) );
|
||||
$this->assertSame( array(), $out['updated'] );
|
||||
$this->assertSame( array( array( 'field' => 'not_a_field', 'reason' => 'field_not_found' ) ), $out['skipped'] );
|
||||
}
|
||||
|
||||
public function test_update_fields_requires_fields_map(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$out = $this->mb->run_metabox_write( array( 'operation' => 'update-fields', 'arguments' => array( 'post_id' => 10 ) ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'missing_params', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_forbidden_via_post_id_when_no_edit_post_cap(): void {
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['post_caps'][10] = false; // edit_posts yes, edit_post(10) no.
|
||||
$out = $this->mb->run_metabox_read( array( 'operation' => 'get-fields', 'arguments' => array( 'post_id' => 10 ) ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'forbidden', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_forbidden_via_object_type_post_when_no_edit_post_cap(): void {
|
||||
// Regression for I-1: object_type=post + object_id must be gated identically to post_id.
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['post_caps'][10] = false; // edit_posts yes, edit_post(10) no.
|
||||
$out = $this->mb->run_metabox_read( array(
|
||||
'operation' => 'get-fields',
|
||||
'arguments' => array( 'object_type' => 'post', 'object_id' => 10 ),
|
||||
) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'forbidden', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_update_fields_forbidden_via_object_type_post_when_no_edit_post_cap(): void {
|
||||
// Same hole on the write path.
|
||||
$this->seed_boxes();
|
||||
$GLOBALS['emcp_test']['posts'][10] = (object) array( 'ID' => 10, 'post_type' => 'event' );
|
||||
$GLOBALS['emcp_test']['post_caps'][10] = false; // edit_posts yes, edit_post(10) no.
|
||||
$out = $this->mb->run_metabox_write( array(
|
||||
'operation' => 'update-fields',
|
||||
'arguments' => array( 'object_type' => 'post', 'object_id' => 10, 'fields' => array( 'venue' => 'Wembley' ) ),
|
||||
) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'forbidden', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_get_fields_object_type_post_unknown_id_returns_post_not_found(): void {
|
||||
// Regression for the carried 404 gap: object_type=post + object_id must 404 just like post_id.
|
||||
$out = $this->mb->run_metabox_read( array(
|
||||
'operation' => 'get-fields',
|
||||
'arguments' => array( 'object_type' => 'post', 'object_id' => 999 ),
|
||||
) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out );
|
||||
$this->assertSame( 'post_not_found', $out->get_error_code() );
|
||||
}
|
||||
|
||||
public function test_no_delete_operation_exists(): void {
|
||||
foreach ( array( 'delete-fields', 'delete-field-group', 'remove-fields' ) as $op ) {
|
||||
$out = $this->mb->run_metabox_write( array( 'operation' => $op ) );
|
||||
$this->assertInstanceOf( WP_Error::class, $out, "op $op must not exist" );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,4 +290,55 @@ function emcp_tools_register_ability( string $name, array $args ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta Box (rwmb_*) stubs, fixture-driven via $GLOBALS['emcp_test']['metabox']
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if ( ! defined( 'RWMB_VER' ) ) { define( 'RWMB_VER', '5.13.1' ); }
|
||||
|
||||
if ( ! class_exists( 'EMCP_Test_MB' ) ) {
|
||||
/** Minimal RW_Meta_Box test double. */
|
||||
class EMCP_Test_MB {
|
||||
public $meta_box;
|
||||
private $object_type;
|
||||
public function __construct( array $meta_box, string $object_type = 'post' ) {
|
||||
$this->meta_box = $meta_box; $this->object_type = $object_type;
|
||||
}
|
||||
public function __get( $k ) { return $this->meta_box[ $k ] ?? null; }
|
||||
public function get_object_type() { return $this->object_type; }
|
||||
}
|
||||
}
|
||||
if ( ! class_exists( 'EMCP_Test_MB_Registry' ) ) {
|
||||
class EMCP_Test_MB_Registry {
|
||||
public function all() { return $GLOBALS['emcp_test']['metabox']['boxes'] ?? array(); }
|
||||
public function get_by( $filter ) {
|
||||
$ot = $filter['object_type'] ?? null;
|
||||
return array_filter( $this->all(), static function ( $mb ) use ( $ot ) {
|
||||
return null === $ot || $mb->get_object_type() === $ot;
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'rwmb_get_registry' ) ) {
|
||||
function rwmb_get_registry( $type ) { return new EMCP_Test_MB_Registry(); }
|
||||
}
|
||||
if ( ! function_exists( 'rwmb_meta' ) ) {
|
||||
function rwmb_meta( $key, $args = array(), $object_id = null ) {
|
||||
$ot = $args['object_type'] ?? 'post';
|
||||
return $GLOBALS['emcp_test']['metabox']['values'][ $ot ][ (string) $object_id ][ $key ] ?? '';
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'rwmb_set_meta' ) ) {
|
||||
function rwmb_set_meta( $object_id, $key, $value, $args = array() ) {
|
||||
$ot = $args['object_type'] ?? 'post';
|
||||
// Emulate MB: no-op for unregistered fields.
|
||||
$known = false;
|
||||
foreach ( $GLOBALS['emcp_test']['metabox']['boxes'] ?? array() as $mb ) {
|
||||
foreach ( (array) $mb->fields as $f ) { if ( ( $f['id'] ?? '' ) === $key ) { $known = true; break 2; } }
|
||||
}
|
||||
if ( $known ) { $GLOBALS['emcp_test']['metabox']['values'][ $ot ][ (string) $object_id ][ $key ] = $value; }
|
||||
}
|
||||
}
|
||||
|
||||
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-acf-abilities.php';
|
||||
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-metabox-abilities.php';
|
||||
|
||||
Reference in New Issue
Block a user