feat: Meta Box custom-fields abilities (metabox-read / metabox-write)

This commit is contained in:
Mrshahidali420
2026-07-16 20:30:08 +05:00
parent 4d3a485abb
commit 1d217e3dbd
8 changed files with 930 additions and 1 deletions
+11
View File
@@ -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 );
}
}
+50 -1
View File
@@ -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' ),
+2
View File
@@ -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.
+1
View File
@@ -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)
+243
View File
@@ -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" );
}
}
}
+51
View File
@@ -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';