`. The * generic post abilities could technically read and write them, but an agent * had to round-trip the whole serialized markup to add a single link, and a * failed attempt pushed it towards creating a brand-new menu instead of * editing the existing one. * * This service exposes menus as structured item trees: listing menus with the * templates that use them, reading a menu as items addressed by path ("0", * "2.1"), and editing it through add / update / remove / move operations that * are validated as a whole before a single write. * * @package AgentMod * @subpackage Services * @since 1.3.2 */ namespace AgentMod\Services; use WP_Error; use WP_Post; defined('ABSPATH') || exit; class NavigationAbilityService { /** * Ability category slug. Shared with the block design abilities. * * @var string * @since 1.3.2 */ private const CATEGORY = 'agent-mod'; /** * Navigation post type. * * @var string * @since 1.3.2 */ private const POST_TYPE = 'wp_navigation'; /** * Blocks whose link attributes can be edited and which can hold children. * * @var string[] * @since 1.3.2 */ private const LINK_BLOCKS = ['core/navigation-link', 'core/navigation-submenu']; /** * Maximum menus returned by list-navigations. * * @var int * @since 1.3.2 */ private const MAX_MENUS = 100; /** * Block markup validator, shared with the post/template write abilities. * * @var BlockMarkupValidator * @since 1.3.2 */ private BlockMarkupValidator $blockMarkupValidator; /** * Constructor. Binds the abilities API hook. * * @param BlockMarkupValidator $blockMarkupValidator Block markup validator. * * @since 1.3.2 */ public function __construct(BlockMarkupValidator $blockMarkupValidator) { $this->blockMarkupValidator = $blockMarkupValidator; add_action('wp_abilities_api_init', [$this, 'registerAbilities']); } /** * Registers the navigation abilities. * * @return void * @since 1.3.2 */ public function registerAbilities(): void { if (! function_exists('wp_register_ability')) { return; } $permission = static function (): bool { return current_user_can('edit_theme_options'); }; $itemsSchema = [ 'type' => 'array', 'description' => __('Menu items as a tree. Each item has a path ("0", "2.1" = second child of the third item) used to address it in update-navigation.', 'agent-mod'), ]; wp_register_ability( 'agent-mod/list-navigations', [ 'label' => __('List Navigation Menus', 'agent-mod'), 'description' => __('Lists the navigation menus (wp_navigation posts) of a block theme with their item count and the templates / template parts that display them (e.g. used_in: footer). Use this first when asked to change "the header menu" or "the footer menu", then edit that menu with update-navigation — never create a new menu to change an existing one.', 'agent-mod'), 'category' => self::CATEGORY, 'execute_callback' => [$this, 'executeListNavigations'], 'permission_callback' => $permission, 'input_schema' => [ 'type' => 'object', 'properties' => [ 's' => [ 'type' => 'string', 'description' => __('Optional. Filter menus by title.', 'agent-mod'), ], ], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'success' => ['type' => 'boolean'], 'navigations' => ['type' => 'array'], 'total' => ['type' => 'integer'], 'error' => ['type' => 'string'], ], ], 'meta' => [ 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], 'show_in_rest' => true, ], ] ); wp_register_ability( 'agent-mod/get-navigation', [ 'label' => __('Get Navigation Menu', 'agent-mod'), 'description' => __('Returns a navigation menu (wp_navigation post) as a tree of items with their path, block type, label, url and link target. navigation_id is the "ref" of a wp:navigation block, or comes from list-navigations. Use the paths with update-navigation.', 'agent-mod'), 'category' => self::CATEGORY, 'execute_callback' => [$this, 'executeGetNavigation'], 'permission_callback' => $permission, 'input_schema' => [ 'type' => 'object', 'required' => ['navigation_id'], 'properties' => [ 'navigation_id' => [ 'type' => 'integer', 'description' => __('wp_navigation post ID (the "ref" attribute of a wp:navigation block).', 'agent-mod'), ], 'include_html' => [ 'type' => 'boolean', 'description' => __('Optional. Also return the raw block markup. Default false; only needed for blocks the item tree does not describe.', 'agent-mod'), ], ], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'success' => ['type' => 'boolean'], 'navigation_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'status' => ['type' => 'string'], 'items' => $itemsSchema, 'html' => ['type' => 'string'], 'error' => ['type' => 'string'], ], ], 'meta' => [ 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], 'show_in_rest' => true, ], ] ); wp_register_ability( 'agent-mod/update-navigation', [ 'label' => __('Update Navigation Menu', 'agent-mod'), 'description' => __('Edits an existing navigation menu in place with operations, so every template that shows the menu picks up the change. "add" appends a link (or inserts it at position / under parent_path), "update" changes an item\'s label/url/target, "remove" deletes an item with its children, "move" relocates it. Operations run in order against the result of the previous one and are all validated before anything is saved: one invalid operation saves nothing. Returns the updated item tree. Alternatively pass html to replace the whole menu markup.', 'agent-mod'), 'category' => self::CATEGORY, 'execute_callback' => [$this, 'executeUpdateNavigation'], 'permission_callback' => $permission, 'input_schema' => [ 'type' => 'object', 'required' => ['navigation_id'], 'properties' => [ 'navigation_id' => [ 'type' => 'integer', 'description' => __('wp_navigation post ID from list-navigations or a wp:navigation block\'s "ref".', 'agent-mod'), ], 'operations' => [ 'type' => 'array', 'description' => __('Ordered edit operations. Paths refer to the menu as it is after the previous operations.', 'agent-mod'), 'items' => [ 'type' => 'object', 'required' => ['action'], 'properties' => [ 'action' => [ 'type' => 'string', 'enum' => ['add', 'update', 'remove', 'move'], ], 'path' => [ 'type' => 'string', 'description' => __('Item path from get-navigation (required for update, remove, move).', 'agent-mod'), ], 'parent_path' => [ 'type' => 'string', 'description' => __('add/move: path of the item to nest under (a link becomes a submenu). Omit for the top level.', 'agent-mod'), ], 'position' => [ 'type' => 'integer', 'description' => __('add/move: the item\'s final 0-based position among its siblings. Omit to append at the end.', 'agent-mod'), ], 'label' => [ 'type' => 'string', 'description' => __('Link text (required for add).', 'agent-mod'), ], 'url' => [ 'type' => 'string', 'description' => __('Link URL. Absolute, relative ("/contact") or an anchor ("#team"). May be omitted when id points to a post/page or term.', 'agent-mod'), ], 'kind' => [ 'type' => 'string', 'enum' => ['custom', 'post-type', 'taxonomy'], 'description' => __('Link kind. Default "custom", or "post-type" when id is given.', 'agent-mod'), ], 'type' => [ 'type' => 'string', 'description' => __('Linked object type, e.g. "page", "post", "category". Default derived from id.', 'agent-mod'), ], 'id' => [ 'type' => 'integer', 'description' => __('Optional linked post/page ID (or term ID with kind "taxonomy"); keeps the link valid when the slug changes.', 'agent-mod'), ], 'opens_in_new_tab' => [ 'type' => 'boolean', ], ], ], ], 'html' => [ 'type' => 'string', 'description' => __('Optional alternative to operations: complete serialized block markup of the menu items (no wrapping wp:navigation block). Replaces the whole menu.', 'agent-mod'), ], 'title' => [ 'type' => 'string', 'description' => __('Optional new menu title.', 'agent-mod'), ], ], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'success' => ['type' => 'boolean'], 'navigation_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'items' => $itemsSchema, 'error' => ['type' => 'string'], ], ], 'meta' => [ 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false], 'show_in_rest' => true, ], ] ); } /** * Execute callback for agent-mod/list-navigations. * * @param mixed $input Input data (optional 's'). * * @return array * @since 1.3.2 */ public function executeListNavigations($input = null): array { $input = is_array($input) ? $input : []; $queryArgs = [ 'post_type' => self::POST_TYPE, 'post_status' => ['publish', 'draft', 'private'], 'posts_per_page' => self::MAX_MENUS, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ]; if (! empty($input['s'])) { $queryArgs['s'] = sanitize_text_field((string) $input['s']); } $usage = $this->collectNavigationUsage(); $navigations = []; foreach (get_posts($queryArgs) as $post) { $navigations[] = [ 'navigation_id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'item_count' => count($this->parseItems($post->post_content)), 'used_in' => $usage[$post->ID] ?? [], ]; } return [ 'success' => true, 'navigations' => $navigations, 'total' => count($navigations), ]; } /** * Execute callback for agent-mod/get-navigation. * * @param mixed $input Input data (expects 'navigation_id', optional 'include_html'). * * @return array * @since 1.3.2 */ public function executeGetNavigation($input = null): array { $post = $this->resolveNavigation($input); if (is_wp_error($post)) { return ['success' => false, 'error' => $post->get_error_message()]; } $result = [ 'success' => true, 'navigation_id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'items' => $this->describeItems($this->parseItems($post->post_content)), ]; if (! empty($input['include_html'])) { $result['html'] = $post->post_content; } return $result; } /** * Execute callback for agent-mod/update-navigation. * * @param mixed $input Input data (expects 'navigation_id' plus 'operations' or 'html', optional 'title'). * * @return array * @since 1.3.2 */ public function executeUpdateNavigation($input = null): array { $post = $this->resolveNavigation($input); if (is_wp_error($post)) { return ['success' => false, 'error' => $post->get_error_message()]; } $hasOperations = ! empty($input['operations']); $hasHtml = isset($input['html']) && '' !== trim((string) $input['html']); $hasTitle = isset($input['title']) && '' !== trim((string) $input['title']); if ($hasOperations && $hasHtml) { return ['success' => false, 'error' => __('Pass either operations or html, not both.', 'agent-mod')]; } if (! $hasOperations && ! $hasHtml && ! $hasTitle) { return ['success' => false, 'error' => __('Nothing to update: provide operations, html or title.', 'agent-mod')]; } $items = null; if ($hasOperations) { if (! is_array($input['operations'])) { return ['success' => false, 'error' => __('operations must be a list of operation objects.', 'agent-mod')]; } $items = $this->parseItems($post->post_content); foreach (array_values($input['operations']) as $index => $operation) { $items = $this->applyOperation($items, is_array($operation) ? $operation : []); if (is_wp_error($items)) { return [ 'success' => false, 'error' => sprintf( /* translators: 1: 0-based operation index, 2: error message. */ __('Operation %1$d failed, nothing was saved: %2$s', 'agent-mod'), $index, $items->get_error_message() ), ]; } } } elseif ($hasHtml) { $items = $this->parseHtmlInput((string) $input['html'], $input); if (is_wp_error($items)) { return ['success' => false, 'error' => $items->get_error_message()]; } } $updateArgs = ['ID' => $post->ID]; if (null !== $items) { $updateArgs['post_content'] = $this->serializeItems($items); } if ($hasTitle) { $updateArgs['post_title'] = sanitize_text_field((string) $input['title']); } // wp_update_post() runs wp_unslash() on the data; slash it so the JSON // escapes inside block attributes survive. $result = wp_update_post(wp_slash($updateArgs), true); if (is_wp_error($result)) { return ['success' => false, 'error' => $result->get_error_message()]; } $updated = get_post($post->ID); return [ 'success' => true, 'navigation_id' => $post->ID, 'title' => $updated instanceof WP_Post ? $updated->post_title : $post->post_title, 'items' => $this->describeItems( $this->parseItems($updated instanceof WP_Post ? $updated->post_content : '') ), ]; } /** * Loads and authorizes the navigation post named by the input. * * @param mixed $input Ability input. * * @return WP_Post|WP_Error * @since 1.3.2 */ private function resolveNavigation($input) { if (! is_array($input) || empty($input['navigation_id'])) { return new WP_Error('missing_id', __('navigation_id is required.', 'agent-mod')); } $id = absint($input['navigation_id']); $post = get_post($id); if (! $post instanceof WP_Post || self::POST_TYPE !== $post->post_type || 'trash' === $post->post_status) { return new WP_Error( 'not_found', sprintf( /* translators: %d: post ID. */ __('Navigation menu %d not found. Call agent-mod/list-navigations for valid IDs.', 'agent-mod'), $id ) ); } if (! current_user_can('edit_post', $id)) { return new WP_Error('forbidden', __('You are not allowed to edit this navigation menu.', 'agent-mod')); } return $post; } /** * Maps navigation IDs to the templates / template parts that reference them. * * @return array Navigation ID => list of "template-part:footer" style labels. * @since 1.3.2 */ private function collectNavigationUsage(): array { $usage = []; if (! function_exists('get_block_templates')) { return $usage; } foreach (['wp_template_part' => 'template-part', 'wp_template' => 'template'] as $templateType => $label) { foreach ((array) get_block_templates([], $templateType) as $template) { $content = (string) ($template->content ?? ''); if (false === strpos($content, 'wp:navigation')) { continue; } foreach ($this->findNavigationRefs(parse_blocks($content)) as $ref) { $usage[$ref][] = $label . ':' . $template->slug; } } } return array_map('array_values', array_map('array_unique', $usage)); } /** * Collects the "ref" IDs of every core/navigation block in a block tree. * * @param array> $blocks Parsed blocks. * * @return int[] * @since 1.3.2 */ private function findNavigationRefs(array $blocks): array { $refs = []; foreach ($blocks as $block) { if ('core/navigation' === ($block['blockName'] ?? null) && ! empty($block['attrs']['ref'])) { $refs[] = (int) $block['attrs']['ref']; } if (! empty($block['innerBlocks'])) { $refs = array_merge($refs, $this->findNavigationRefs($block['innerBlocks'])); } } return $refs; } /** * Parses menu markup into named blocks, dropping whitespace-only filler. * * @param string $content Serialized block markup. * * @return array> * @since 1.3.2 */ private function parseItems(string $content): array { return array_values(array_filter(parse_blocks($content), static function ($block): bool { return ! empty($block['blockName']) || '' !== trim((string) ($block['innerHTML'] ?? '')); })); } /** * Validates and parses the html alternative of update-navigation. * * Applies the same gates as the post/template write abilities. * * @param string $html Serialized block markup. * @param array $input The ability input. * * @return array>|WP_Error * @since 1.3.2 */ private function parseHtmlInput(string $html, array $input) { /** This filter is documented in includes/Services/AbilityRegistrarService.php */ $html = apply_filters('agent_mod_resolve_blocks_html', $html, $input); if (is_wp_error($html)) { return $html; } $html = (string) $html; if (preg_match('#