From 1d217e3dbdde4bf4d4650334d76fd91cddca76e9 Mon Sep 17 00:00:00 2001 From: Mrshahidali420 <175938180+Mrshahidali420@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:30:08 +0500 Subject: [PATCH] feat: Meta Box custom-fields abilities (metabox-read / metabox-write) --- README.md | 11 + .../abilities/class-ability-registrar.php | 7 + .../abilities/class-metabox-abilities.php | 565 ++++++++++++++++++ includes/admin/class-admin.php | 51 +- includes/class-bootstrap.php | 2 + readme.txt | 1 + tests/MetaBoxAbilitiesTest.php | 243 ++++++++ tests/bootstrap.php | 51 ++ 8 files changed, 930 insertions(+), 1 deletion(-) create mode 100644 includes/abilities/class-metabox-abilities.php create mode 100644 tests/MetaBoxAbilitiesTest.php diff --git a/README.md b/README.md index bb206e0..f9fab97 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/includes/abilities/class-ability-registrar.php b/includes/abilities/class-ability-registrar.php index df32032..dcac6e8 100644 --- a/includes/abilities/class-ability-registrar.php +++ b/includes/abilities/class-ability-registrar.php @@ -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(); diff --git a/includes/abilities/class-metabox-abilities.php b/includes/abilities/class-metabox-abilities.php new file mode 100644 index 0000000..57edc58 --- /dev/null +++ b/includes/abilities/class-metabox-abilities.php @@ -0,0 +1,565 @@ +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> + */ + 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": "", "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 + */ + 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 ); + } +} diff --git a/includes/admin/class-admin.php b/includes/admin/class-admin.php index 73e99f7..a6c23b7 100644 --- a/includes/admin/class-admin.php +++ b/includes/admin/class-admin.php @@ -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' ), diff --git a/includes/class-bootstrap.php b/includes/class-bootstrap.php index e1c5de5..de4a2a5 100644 --- a/includes/class-bootstrap.php +++ b/includes/class-bootstrap.php @@ -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. diff --git a/readme.txt b/readme.txt index 769863a..45545cf 100644 --- a/readme.txt +++ b/readme.txt @@ -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) diff --git a/tests/MetaBoxAbilitiesTest.php b/tests/MetaBoxAbilitiesTest.php new file mode 100644 index 0000000..54172de --- /dev/null +++ b/tests/MetaBoxAbilitiesTest.php @@ -0,0 +1,243 @@ +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" ); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 65dbd53..99f967e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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';