*/ public static function abilities(): array { $allowed = static fn (array $args): bool => current_user_can('edit_theme_options'); $itemFields = [ 'objectId' => ['description' => 'The page or post id the item links to (sets a page link instead of a custom url)', 'minimum' => 1, 'type' => 'integer'], 'parent' => ['description' => 'The parent menu item id, 0 for top level', 'minimum' => 0, 'type' => 'integer'], 'position' => ['minimum' => 1, 'type' => 'integer'], 'title' => ['maxLength' => 200, 'type' => 'string'], 'url' => ['maxLength' => 2000, 'type' => 'string'], ]; return [ new Ability( 'sarcio/menus-list', __('Look at the menus', 'sarcio'), Ability::READ, 'Every navigation menu with its items (id, title, url, parent item id, order, what it links to) and which theme location each menu is assigned to.', ['properties' => [], 'type' => 'object'], $allowed, static fn (array $args): array => self::list(), ), new Ability( 'sarcio/menu-item-add', __('Add a menu item', 'sarcio'), Ability::WRITE, 'Add an item to a navigation menu: a link to a page or post (objectId) or a custom url, with a title, optional parent item and position.', [ 'properties' => ['menuId' => ['minimum' => 1, 'type' => 'integer']] + $itemFields, 'required' => ['menuId', 'title'], 'type' => 'object', ], $allowed, static fn (array $args): array => self::add($args), null, static fn (array $before, array $after): array => self::delete((int) ($after['id'] ?? 0)), false, static fn (array $args, Overlay $overlay): array => self::stageAdd($args, $overlay), ), new Ability( 'sarcio/menu-item-update', __('Change a menu item', 'sarcio'), Ability::WRITE, 'Change a navigation menu item’s title, url or linked page, parent or position, by its item id from menus-list (an item added in the preview has a negative id). Only the fields given change.', [ 'properties' => ['id' => ['type' => 'integer']] + $itemFields, 'required' => ['id'], 'type' => 'object', ], $allowed, static fn (array $args): array => self::update($args), null, static fn (array $before, array $after): array => self::save((int) $before['menuId'], (int) $before['id'], $before) + ['restored' => true], false, static fn (array $args, Overlay $overlay): array => self::stageUpdate($args, $overlay), ), new Ability( 'sarcio/menu-item-remove', __('Remove a menu item', 'sarcio'), Ability::WRITE, 'Remove an item from a navigation menu by its item id from menus-list (an item added in the preview has a negative id). The page it linked to is not touched.', ['properties' => ['id' => ['type' => 'integer']], 'required' => ['id'], 'type' => 'object'], $allowed, static fn (array $args): array => self::remove((int) $args['id']), null, static fn (array $before, array $after): array => self::save((int) $before['menuId'], 0, $before) + ['restored' => true], false, static fn (array $args, Overlay $overlay): array => self::stageRemove((int) $args['id'], $overlay), ), ]; } /** @return array */ public static function snapshot(object $item, int $menuId): array { return [ 'id' => (int) $item->ID, 'menuId' => $menuId, 'object' => (string) ($item->object ?? ''), 'objectId' => (int) ($item->object_id ?? 0), 'parent' => (int) ($item->menu_item_parent ?? 0), 'position' => (int) ($item->menu_order ?? 0), 'title' => (string) ($item->title ?? ''), 'type' => (string) ($item->type ?? 'custom'), 'url' => (string) ($item->url ?? ''), ]; } /** @param array $fields */ public static function save(int $menuId, int $itemId, array $fields): array { $isPost = (int) ($fields['objectId'] ?? 0) > 0; $data = [ 'menu-item-object' => $isPost ? (string) ($fields['object'] ?: get_post_type((int) $fields['objectId']) ?: 'page') : 'custom', 'menu-item-object-id' => $isPost ? (int) $fields['objectId'] : 0, 'menu-item-parent-id' => (int) ($fields['parent'] ?? 0), 'menu-item-position' => (int) ($fields['position'] ?? 0), 'menu-item-status' => 'publish', 'menu-item-title' => sanitize_text_field((string) ($fields['title'] ?? '')), 'menu-item-type' => $isPost ? 'post_type' : 'custom', 'menu-item-url' => $isPost ? '' : esc_url_raw((string) ($fields['url'] ?? '')), ]; $result = wp_update_nav_menu_item($menuId, $itemId, $data); if (is_wp_error($result) || (int) $result <= 0) { return ['error' => __('WordPress could not save the menu item.', 'sarcio')]; } return ['id' => (int) $result, 'menuId' => $menuId]; } private static function list(): array { $locations = function_exists('get_nav_menu_locations') ? (array) get_nav_menu_locations() : []; $registered = function_exists('get_registered_nav_menus') ? (array) get_registered_nav_menus() : []; $menus = []; foreach ((array) wp_get_nav_menus() as $menu) { $items = []; foreach (Stage::menuItems((int) $menu->term_id) as $item) { $items[] = self::snapshot($item, (int) $menu->term_id); } $assigned = []; foreach ($locations as $location => $menuId) { if ((int) $menuId === (int) $menu->term_id) { $assigned[] = ['location' => (string) $location, 'name' => (string) ($registered[$location] ?? $location)]; } } $menus[] = ['id' => (int) $menu->term_id, 'items' => $items, 'locations' => $assigned, 'name' => (string) $menu->name]; } return ['menus' => $menus]; } /** @return array{item?:array,error?:string} */ private static function find(int $itemId): array { $post = get_post($itemId); if ($post === null || (string) $post->post_type !== 'nav_menu_item') { return ['error' => __('There is no menu item with that id.', 'sarcio')]; } $terms = wp_get_post_terms($itemId, 'nav_menu'); $menuId = is_array($terms) && isset($terms[0]) ? (int) $terms[0]->term_id : 0; if ($menuId <= 0) { return ['error' => __('That menu item is not in any menu.', 'sarcio')]; } return ['item' => self::snapshot(wp_setup_nav_menu_item($post), $menuId)]; } /** * The item as the preview shows it: one the preview added (a negative id), * or a real one with the preview's edits, unless the preview removed it. * * @return array{item?:array,error?:string} */ private static function locate(int $itemId, Overlay $overlay): array { if ($itemId < 0) { foreach ($overlay->menus as $menuId => $edits) { foreach ($edits['added'] as $added) { if ((int) $added['ID'] === $itemId) { return ['item' => self::snapshot((object) $added, (int) $menuId)]; } } } return ['error' => __('There is no menu item with that id.', 'sarcio')]; } $found = self::find($itemId); if (isset($found['error'])) { return $found; } $edits = $overlay->menus[(int) $found['item']['menuId']] ?? null; if ($edits !== null && in_array($itemId, $edits['removed'], true)) { return ['error' => __('That menu item is removed in the preview.', 'sarcio')]; } if ($edits !== null && isset($edits['updated'][$itemId])) { $post = get_post($itemId); $item = wp_setup_nav_menu_item($post); foreach ($edits['updated'][$itemId] as $key => $value) { $item->$key = $value; } return ['item' => self::snapshot($item, (int) $found['item']['menuId'])]; } return $found; } /** * @param array $item the snapshot shape * @return array nav menu item object fields */ private static function itemFields(array $item): array { return [ 'menu_item_parent' => (int) $item['parent'], 'menu_order' => (int) $item['position'], 'object' => (string) $item['object'], 'object_id' => (int) $item['objectId'], 'post_title' => (string) $item['title'], 'title' => (string) $item['title'], 'type' => (string) $item['type'], 'url' => (string) $item['url'], ]; } /** @param array $args */ private static function add(array $args): array { $menu = wp_get_nav_menu_object((int) $args['menuId']); if (!$menu) { return ['error' => __('There is no menu with that id.', 'sarcio')]; } $saved = self::save((int) $args['menuId'], 0, $args); if (isset($saved['error'])) { return $saved; } $after = self::find((int) $saved['id'])['item'] ?? ['id' => $saved['id'], 'menuId' => (int) $args['menuId']] + $args; return [ 'id' => (int) $saved['id'], 'journal' => [ 'after' => $after, 'before' => [], 'summary' => sprintf( /* translators: 1: the item title, 2: the menu name. */ __('Added “%1$s” to the %2$s menu', 'sarcio'), (string) $args['title'], (string) $menu->name, ), 'target' => 'menu:' . $args['menuId'], ], ]; } /** @param array $args */ private static function stageAdd(array $args, Overlay $overlay): array { $menu = wp_get_nav_menu_object((int) $args['menuId']); if (!$menu) { return ['error' => __('There is no menu with that id.', 'sarcio')]; } $link = (int) ($args['objectId'] ?? 0) > 0 ? get_the_title((int) $args['objectId']) : (string) ($args['url'] ?? ''); $id = $overlay->addMenuItem((int) $args['menuId'], $args); return [ 'after' => (string) $args['title'] . ($link !== '' ? ' → ' . $link : ''), 'id' => $id, 'before' => '', 'summary' => sprintf( /* translators: 1: the item title, 2: the menu name. */ __('Add “%1$s” to the %2$s menu', 'sarcio'), (string) $args['title'], (string) $menu->name, ), ]; } /** @param array $args */ private static function update(array $args): array { $found = self::find((int) $args['id']); if (isset($found['error'])) { return $found; } $before = $found['item']; $after = self::merge($before, $args); if ($after === $before) { return ['error' => __('Nothing would change.', 'sarcio')]; } $saved = self::save((int) $before['menuId'], (int) $before['id'], $after); if (isset($saved['error'])) { return $saved; } return [ 'id' => (int) $before['id'], 'journal' => [ 'after' => $after, 'before' => $before, 'summary' => sprintf( /* translators: %s: the item title. */ __('Changed the menu item “%s”', 'sarcio'), (string) $before['title'], ), 'target' => 'menu:' . $before['menuId'], ], ]; } /** @param array $args */ private static function stageUpdate(array $args, Overlay $overlay): array { $found = self::locate((int) $args['id'], $overlay); if (isset($found['error'])) { return $found; } $before = $found['item']; $after = self::merge($before, $args); if ($after === $before) { return ['error' => __('Nothing would change.', 'sarcio')]; } $overlay->updateMenuItem((int) $before['menuId'], (int) $before['id'], self::itemFields($after)); return [ 'after' => self::describe($after), 'before' => self::describe($before), 'summary' => sprintf( /* translators: %s: the item title. */ __('Change the menu item “%s”', 'sarcio'), (string) $before['title'], ), ]; } private static function remove(int $itemId): array { $found = self::find($itemId); if (isset($found['error'])) { return $found; } $deleted = self::delete($itemId); if (isset($deleted['error'])) { return $deleted; } return [ 'id' => $itemId, 'journal' => [ 'after' => [], 'before' => $found['item'], 'summary' => sprintf( /* translators: %s: the item title. */ __('Removed the menu item “%s”', 'sarcio'), (string) $found['item']['title'], ), 'target' => 'menu:' . $found['item']['menuId'], ], ]; } private static function stageRemove(int $itemId, Overlay $overlay): array { $found = self::locate($itemId, $overlay); if (isset($found['error'])) { return $found; } $overlay->removeMenuItem((int) $found['item']['menuId'], $itemId); return [ 'after' => '', 'before' => self::describe($found['item']), 'summary' => sprintf( /* translators: %s: the item title. */ __('Remove the menu item “%s”', 'sarcio'), (string) $found['item']['title'], ), ]; } private static function delete(int $itemId): array { if ($itemId <= 0 || get_post($itemId) === null) { return ['error' => __('That menu item no longer exists.', 'sarcio')]; } if (!wp_delete_post($itemId, true)) { return ['error' => __('WordPress could not remove the menu item.', 'sarcio')]; } return ['id' => $itemId, 'removed' => true]; } /** * @param array $before * @param array $args */ private static function merge(array $before, array $args): array { $after = $before; foreach (['title', 'url', 'objectId', 'parent', 'position'] as $field) { if (array_key_exists($field, $args)) { $after[$field] = $args[$field]; } } if (array_key_exists('url', $args) && !array_key_exists('objectId', $args)) { $after['objectId'] = 0; $after['object'] = 'custom'; $after['type'] = 'custom'; } if (array_key_exists('objectId', $args)) { $after['object'] = (string) (get_post_type((int) $args['objectId']) ?: 'page'); $after['type'] = 'post_type'; $after['url'] = (string) get_permalink((int) $args['objectId']); } return $after; } /** @param array $item */ private static function describe(array $item): string { $parts = [(string) $item['title']]; if ((int) $item['objectId'] > 0) { $parts[] = get_the_title((int) $item['objectId']); } elseif ((string) $item['url'] !== '') { $parts[] = (string) $item['url']; } if ((int) $item['parent'] > 0) { $parts[] = sprintf( /* translators: %s: the parent item title. */ __('under %s', 'sarcio'), get_the_title((int) $item['parent']), ); } return implode(' · ', $parts); } }