feat: declarative and operation-based menu item editing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Applies add/move/remove operations to the current menu tree and returns the
|
||||
* desired tree for MenuWriter – so single operations and the declarative mode
|
||||
* share one validated write path.
|
||||
*/
|
||||
final class MenuOperations
|
||||
{
|
||||
private const ROOT = 'root';
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tree Current tree (MenuTree::build()).
|
||||
* @param list<mixed> $operations
|
||||
* @return list<array<string, mixed>>|WP_Error
|
||||
*/
|
||||
public function apply(array $tree, array $operations): array|WP_Error
|
||||
{
|
||||
$nodes = [];
|
||||
$children = [self::ROOT => []];
|
||||
$this->flatten($tree, self::ROOT, $nodes, $children);
|
||||
|
||||
foreach ($operations as $index => $operation) {
|
||||
$error = $this->applyOne($operation, (int) $index, $nodes, $children);
|
||||
if ($error !== null) {
|
||||
return Errors::invalidInput(
|
||||
'Menü-Operation ungültig; nichts wurde geändert.',
|
||||
['operations.' . $index => $error]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->nest(self::ROOT, $nodes, $children);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tree
|
||||
* @param array<string, array<string, mixed>> $nodes
|
||||
* @param array<string, list<string>> $children
|
||||
*/
|
||||
private function flatten(array $tree, string $parent, array &$nodes, array &$children): void
|
||||
{
|
||||
foreach ($tree as $item) {
|
||||
$key = 'id:' . (int) $item['id'];
|
||||
$nodes[$key] = ['id' => (int) $item['id']];
|
||||
$children[$parent][] = $key;
|
||||
$children[$key] = [];
|
||||
$this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $nodes
|
||||
* @param array<string, list<string>> $children
|
||||
*/
|
||||
private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string
|
||||
{
|
||||
if (! is_array($operation)) {
|
||||
return 'Objekt erwartet.';
|
||||
}
|
||||
|
||||
$parentId = (int) ($operation['parent_id'] ?? 0);
|
||||
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
|
||||
if (! isset($children[$parentKey])) {
|
||||
return 'parent_id ist kein Eintrag dieses Menüs.';
|
||||
}
|
||||
|
||||
$position = isset($operation['position']) ? (int) $operation['position'] : null;
|
||||
$key = 'id:' . (int) ($operation['id'] ?? 0);
|
||||
|
||||
switch ($operation['op'] ?? '') {
|
||||
case 'add':
|
||||
if (! is_array($operation['item'] ?? null)) {
|
||||
return 'item fehlt.';
|
||||
}
|
||||
$newKey = 'new:' . $index;
|
||||
$nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]);
|
||||
$children[$newKey] = [];
|
||||
$this->insert($children[$parentKey], $newKey, $position);
|
||||
return null;
|
||||
case 'move':
|
||||
if (! isset($nodes[$key])) {
|
||||
return 'id ist kein Eintrag dieses Menüs.';
|
||||
}
|
||||
if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) {
|
||||
return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.';
|
||||
}
|
||||
$this->detach($key, $children);
|
||||
$this->insert($children[$parentKey], $key, $position);
|
||||
return null;
|
||||
case 'remove':
|
||||
if (! isset($nodes[$key])) {
|
||||
return 'id ist kein Eintrag dieses Menüs.';
|
||||
}
|
||||
[$owner, $at] = $this->detach($key, $children);
|
||||
array_splice($children[$owner], $at, 0, $children[$key]);
|
||||
unset($nodes[$key], $children[$key]);
|
||||
return null;
|
||||
default:
|
||||
return 'op muss add, move oder remove sein.';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $list */
|
||||
private function insert(array &$list, string $key, ?int $position): void
|
||||
{
|
||||
if ($position === null || $position >= count($list)) {
|
||||
$list[] = $key;
|
||||
return;
|
||||
}
|
||||
|
||||
array_splice($list, max(0, $position), 0, [$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $children
|
||||
* @return array{0: string, 1: int} Former parent key and index.
|
||||
*/
|
||||
private function detach(string $key, array &$children): array
|
||||
{
|
||||
foreach ($children as $parent => $list) {
|
||||
$index = array_search($key, $list, true);
|
||||
if ($index !== false) {
|
||||
array_splice($children[$parent], (int) $index, 1);
|
||||
return [(string) $parent, (int) $index];
|
||||
}
|
||||
}
|
||||
|
||||
return [self::ROOT, count($children[self::ROOT])];
|
||||
}
|
||||
|
||||
/** @param array<string, list<string>> $children */
|
||||
private function isDescendant(string $candidate, string $ancestor, array $children): bool
|
||||
{
|
||||
foreach ($children[$ancestor] ?? [] as $child) {
|
||||
if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $nodes
|
||||
* @param array<string, list<string>> $children
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function nest(string $parent, array $nodes, array $children): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($children[$parent] ?? [] as $key) {
|
||||
$result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-set-items: sets a menu's items, declaratively (items = whole target tree) or
|
||||
* as add/move/remove operations. Both run through one path: operations are applied to
|
||||
* the current tree (MenuOperations), the resulting tree is validated as a whole
|
||||
* (MenuWriter::plan) before anything is written, removals need confirm: true, and the
|
||||
* answer always carries previous_items so a mistake can be rebuilt. Write failures
|
||||
* surface as WriteFailed (lit_write_failed / lit_partial_write) from MenuWriter::apply.
|
||||
*/
|
||||
final class MenuSetItemsAbility implements Ability
|
||||
{
|
||||
public function __construct(
|
||||
private MenuWriter $writer,
|
||||
private MenuOperations $operations,
|
||||
private ChangeLog $log
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-set-items';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menüeinträge setzen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Setzt die Einträge eines Menüs. Entweder items = vollständiger Zielbaum oder operations = Liste aus '
|
||||
. '{op: add, item, parent_id?, position?}, {op: move, id, parent_id?, position?}, {op: remove, id} '
|
||||
. '(Kinder eines entfernten Eintrags rücken an seine Stelle; position 0-basiert, ohne = ans Ende). '
|
||||
. 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge '
|
||||
. 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ '
|
||||
. 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom '
|
||||
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id (muss für dich lesbar sein) oder '
|
||||
. 'custom mit url (/pfad, #anker, http(s), mailto, tel) und title, jeweils optional children. Das '
|
||||
. 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann '
|
||||
. 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – '
|
||||
. 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält '
|
||||
. 'previous_items (Stand vorher) zum Zurückbauen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'menu_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'object'],
|
||||
'description' => 'Vollständiger Zielbaum (Knoten wie in lit-menu-get, neue Einträge ohne id).',
|
||||
],
|
||||
'operations' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']],
|
||||
'id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'item' => ['type' => 'object'],
|
||||
'parent_id' => ['type' => 'integer', 'minimum' => 0],
|
||||
'position' => ['type' => 'integer', 'minimum' => 0],
|
||||
],
|
||||
'required' => ['op'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
],
|
||||
'confirm' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Nur nötig, wenn Einträge entfernt werden.',
|
||||
],
|
||||
],
|
||||
'required' => ['menu_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
$tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()];
|
||||
|
||||
return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||||
$menu = wp_get_nav_menu_object($menuId);
|
||||
if (! is_object($menu)) {
|
||||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||||
}
|
||||
|
||||
if (array_key_exists('items', $input) === array_key_exists('operations', $input)) {
|
||||
return Errors::invalidInput(
|
||||
'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.',
|
||||
['items' => 'oder operations']
|
||||
);
|
||||
}
|
||||
|
||||
$current = $this->currentItems($menuId);
|
||||
$previous = MenuTree::build(array_values($current));
|
||||
|
||||
$tree = array_key_exists('items', $input)
|
||||
? (array) $input['items']
|
||||
: $this->operations->apply($previous, (array) $input['operations']);
|
||||
if ($tree instanceof WP_Error) {
|
||||
return $tree;
|
||||
}
|
||||
|
||||
$plan = $this->writer->plan($tree, $current);
|
||||
if ($plan['errors'] !== []) {
|
||||
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
|
||||
}
|
||||
|
||||
if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'Folgende Menüeinträge würden endgültig entfernt: %s.',
|
||||
implode(', ', array_column($plan['removals'], 'title'))
|
||||
),
|
||||
['removals' => $plan['removals']]
|
||||
);
|
||||
}
|
||||
|
||||
// Throws WriteFailed (after its own rollback) – the registrar turns it into the error.
|
||||
$stats = $this->writer->apply($menuId, $tree, $current);
|
||||
|
||||
// The menu is saved – from here on failures are logged and reported as warnings only.
|
||||
$warnings = $plan['warnings'];
|
||||
$summary = sprintf(
|
||||
'Menü "%s": %d neu, %d geändert, %d entfernt',
|
||||
$menu->name,
|
||||
$stats['created'],
|
||||
$stats['updated'],
|
||||
$stats['removed']
|
||||
);
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
$after = FollowUp::run(
|
||||
fn () => MenuTree::build(array_values($this->currentItems($menuId))),
|
||||
$this->name() . ' re-read',
|
||||
'Das Menü ist gespeichert, der neue Stand konnte aber nicht gelesen werden; mit lit-menu-get abrufen.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok($summary . '.')
|
||||
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
|
||||
->warnings($warnings)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->with('items', is_array($after) ? $after : [])
|
||||
->with('previous_items', $previous)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @return array<int, object> The menu's items by ID, in menu order. */
|
||||
private function currentItems(int $menuId): array
|
||||
{
|
||||
$items = wp_get_nav_menu_items($menuId);
|
||||
$current = [];
|
||||
foreach (is_array($items) ? $items : [] as $item) {
|
||||
$current[(int) $item->ID] = $item;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
/**
|
||||
* Validates a desired menu tree against the current items and writes it.
|
||||
*
|
||||
* Node shape = the lit/menu-get node (P4 ruling): an existing item is identified by
|
||||
* its id alone; type/object/object_id and – for post/taxonomy items – url are
|
||||
* informational and ignored. Existing items keep all their data (classes, target,
|
||||
* description, custom label, …); title only becomes a custom label when it differs
|
||||
* from the currently shown title, url only changes for custom items.
|
||||
*
|
||||
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
|
||||
* items it created and rewrites the touched items with their previous data. Deleted
|
||||
* menu items cannot be restored with their IDs, so a failure after a deletion is a
|
||||
* partial write. Not final so the ability can be tested with a mock.
|
||||
*/
|
||||
class MenuWriter
|
||||
{
|
||||
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
|
||||
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
||||
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
|
||||
|
||||
/** @var list<int> Items created by the running apply(). */
|
||||
private array $created = [];
|
||||
|
||||
/** @var list<int> Existing items rewritten by the running apply(). */
|
||||
private array $touched = [];
|
||||
|
||||
/** @var list<int> Items deleted by the running apply(). */
|
||||
private array $deleted = [];
|
||||
|
||||
/**
|
||||
* @param list<mixed> $tree
|
||||
* @param array<int, object> $current Current items by ID.
|
||||
* @return array{
|
||||
* errors: array<string, string>,
|
||||
* removals: list<array{id: int, title: string}>,
|
||||
* warnings: list<string>
|
||||
* }
|
||||
*/
|
||||
public function plan(array $tree, array $current): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$seen = [];
|
||||
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
|
||||
|
||||
$removals = [];
|
||||
foreach ($current as $id => $item) {
|
||||
if (! isset($seen[$id])) {
|
||||
$removals[] = ['id' => (int) $id, 'title' => (string) $item->title];
|
||||
}
|
||||
}
|
||||
|
||||
return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tree Validated by plan().
|
||||
* @param array<int, object> $current Current items by ID, in menu order.
|
||||
* @return array{created: int, updated: int, removed: int}
|
||||
* @throws WriteFailed When saving failed; rolledBack tells whether the previous tree was restored.
|
||||
*/
|
||||
public function apply(int $menuId, array $tree, array $current): array
|
||||
{
|
||||
$this->created = [];
|
||||
$this->touched = [];
|
||||
$this->deleted = [];
|
||||
|
||||
try {
|
||||
$position = 0;
|
||||
$seen = [];
|
||||
$this->writeNodes($menuId, $tree, 0, $current, $position, $seen);
|
||||
|
||||
foreach (array_keys($current) as $id) {
|
||||
if (! isset($seen[$id])) {
|
||||
$this->remove((int) $id);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->fail($menuId, $current, $e);
|
||||
}
|
||||
|
||||
return [
|
||||
'created' => count($this->created),
|
||||
'updated' => count($this->touched),
|
||||
'removed' => count($this->deleted),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, object> $current
|
||||
* @param array<int, true> $seen
|
||||
* @param array<string, string> $errors
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private function validateNodes(
|
||||
mixed $nodes,
|
||||
string $path,
|
||||
array $current,
|
||||
array &$seen,
|
||||
array &$errors,
|
||||
array &$warnings
|
||||
): void {
|
||||
if (! is_array($nodes) || ! array_is_list($nodes)) {
|
||||
$errors[$path] = 'Liste erwartet.';
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($nodes as $index => $node) {
|
||||
$nodePath = $path . '.' . $index;
|
||||
if (! is_array($node)) {
|
||||
$errors[$nodePath] = 'Objekt erwartet.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$error = self::isExisting($node)
|
||||
? $this->checkExisting($node, $current, $seen)
|
||||
: $this->checkNew($node, $warnings);
|
||||
if ($error !== null) {
|
||||
$errors[$nodePath] = $error;
|
||||
}
|
||||
|
||||
if (array_key_exists('children', $node)) {
|
||||
$this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node A node without id (or id 0/null) is a new item. */
|
||||
private static function isExisting(array $node): bool
|
||||
{
|
||||
return isset($node['id']) && $node['id'] !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param array<int, object> $current
|
||||
* @param array<int, true> $seen
|
||||
*/
|
||||
private function checkExisting(array $node, array $current, array &$seen): ?string
|
||||
{
|
||||
$id = $node['id'];
|
||||
if (! is_int($id) || ! isset($current[$id])) {
|
||||
return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).';
|
||||
}
|
||||
|
||||
if (isset($seen[$id])) {
|
||||
return sprintf('Eintrag %d kommt mehrfach vor.', $id);
|
||||
}
|
||||
$seen[$id] = true;
|
||||
|
||||
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
|
||||
return 'title muss Text sein.';
|
||||
}
|
||||
|
||||
// P4: url of post/taxonomy items is derived by WordPress – informational, ignored.
|
||||
if ((string) $current[$id]->type === 'custom' && $this->urlChanges($node, $current[$id])) {
|
||||
return $this->validUrl($node['url']) ? null : 'Ungültige url (erlaubt: ' . self::URL_RULE . ').';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
private function checkNew(array $node, array &$warnings): ?string
|
||||
{
|
||||
if (array_key_exists('title', $node) && ! is_string($node['title'])) {
|
||||
return 'title muss Text sein.';
|
||||
}
|
||||
|
||||
$objectId = $node['object_id'] ?? null;
|
||||
|
||||
switch ($node['type'] ?? null) {
|
||||
case 'post':
|
||||
$post = is_int($objectId) ? get_post($objectId) : null;
|
||||
// Same answer for "missing" and "not readable": no probing of private posts via menus.
|
||||
if (
|
||||
! $post instanceof WP_Post
|
||||
|| in_array($post->post_type, self::NON_LINKABLE, true)
|
||||
|| ! current_user_can('read_post', $objectId)
|
||||
) {
|
||||
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
|
||||
}
|
||||
if ($post->post_status !== 'publish') {
|
||||
$warnings[] = sprintf(
|
||||
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
|
||||
$post->post_title
|
||||
);
|
||||
}
|
||||
return null;
|
||||
case 'taxonomy':
|
||||
$term = is_int($objectId) ? get_term($objectId) : null;
|
||||
return $term instanceof WP_Term
|
||||
&& (is_taxonomy_viewable($term->taxonomy) || current_user_can('edit_term', $objectId))
|
||||
? null
|
||||
: 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.';
|
||||
case 'custom':
|
||||
if (! $this->validUrl($node['url'] ?? null)) {
|
||||
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
|
||||
}
|
||||
return is_string($node['title'] ?? null) && trim($node['title']) !== ''
|
||||
? null
|
||||
: 'Individueller Link braucht einen title.';
|
||||
default:
|
||||
return 'type muss post, taxonomy oder custom sein.';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node */
|
||||
private function urlChanges(array $node, object $item): bool
|
||||
{
|
||||
return array_key_exists('url', $node) && $node['url'] !== (string) $item->url;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node */
|
||||
private function titleChanges(array $node, object $item): bool
|
||||
{
|
||||
return isset($node['title']) && (string) $node['title'] !== (string) $item->title;
|
||||
}
|
||||
|
||||
private function validUrl(mixed $url): bool
|
||||
{
|
||||
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
|
||||
return ! str_starts_with($url, '//');
|
||||
}
|
||||
|
||||
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
|
||||
|
||||
return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $nodes
|
||||
* @param array<int, object> $current
|
||||
* @param array<int, true> $seen
|
||||
*/
|
||||
private function writeNodes(
|
||||
int $menuId,
|
||||
array $nodes,
|
||||
int $parentId,
|
||||
array $current,
|
||||
int &$position,
|
||||
array &$seen
|
||||
): void {
|
||||
foreach ($nodes as $node) {
|
||||
$position++;
|
||||
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
|
||||
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
|
||||
if ($existing === null ? isset($node['title']) : $this->titleChanges($node, $existing)) {
|
||||
$args['menu-item-title'] = (string) $node['title'];
|
||||
}
|
||||
if ($existing === null ? $node['type'] === 'custom' : $this->customUrlChanges($node, $existing)) {
|
||||
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
||||
}
|
||||
$args['menu-item-parent-id'] = $parentId;
|
||||
$args['menu-item-position'] = $position;
|
||||
$args['menu-item-status'] = 'publish';
|
||||
|
||||
$itemId = $this->save($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
|
||||
if ($existing !== null) {
|
||||
$seen[(int) $existing->ID] = true;
|
||||
$this->touched[] = (int) $existing->ID;
|
||||
} else {
|
||||
$this->created[] = $itemId;
|
||||
}
|
||||
|
||||
$this->writeNodes($menuId, (array) ($node['children'] ?? []), $itemId, $current, $position, $seen);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $node */
|
||||
private function customUrlChanges(array $node, object $item): bool
|
||||
{
|
||||
return (string) $item->type === 'custom' && $this->urlChanges($node, $item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function save(int $menuId, int $itemId, array $args): int
|
||||
{
|
||||
$result = wp_update_nav_menu_item($menuId, $itemId, $args);
|
||||
if (is_wp_error($result) || (int) $result <= 0) {
|
||||
$message = is_wp_error($result) ? $result->get_error_message() : 'no item ID returned';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- never rendered as HTML
|
||||
throw new RuntimeException(sprintf('Menu item %d could not be saved: %s', $itemId, $message));
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/** @throws RuntimeException */
|
||||
private function remove(int $itemId): void
|
||||
{
|
||||
// Menu items have no trash in WordPress; the core menu editor deletes them the same way.
|
||||
$deleted = wp_delete_post($itemId, true);
|
||||
if (! $deleted instanceof WP_Post) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- never rendered as HTML
|
||||
throw new RuntimeException(sprintf('Menu item %d could not be deleted.', $itemId));
|
||||
}
|
||||
|
||||
$this->deleted[] = $itemId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the running apply() and throws. Created items are deleted, touched items get
|
||||
* their previous data, parent and position back. Deleted items cannot come back with
|
||||
* their IDs – then (or when a restore step fails) the write is partial.
|
||||
*
|
||||
* @param array<int, object> $current
|
||||
* @throws WriteFailed Always.
|
||||
*/
|
||||
private function fail(int $menuId, array $current, Throwable $cause): never
|
||||
{
|
||||
$problems = [];
|
||||
foreach ($this->created as $itemId) {
|
||||
if (! wp_delete_post($itemId, true) instanceof WP_Post) {
|
||||
$problems[] = sprintf('created item %d not deleted', $itemId);
|
||||
}
|
||||
}
|
||||
|
||||
$order = array_flip(array_keys($current));
|
||||
foreach ($this->touched as $itemId) {
|
||||
$item = $current[$itemId];
|
||||
$args = $this->argsFromItem($item);
|
||||
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
|
||||
$args['menu-item-position'] = isset($item->menu_order) && (int) $item->menu_order > 0
|
||||
? (int) $item->menu_order
|
||||
: $order[$itemId] + 1;
|
||||
$args['menu-item-status'] = 'publish';
|
||||
try {
|
||||
$this->save($menuId, $itemId, $args);
|
||||
} catch (Throwable $e) {
|
||||
$problems[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$rolledBack = $problems === [] && $this->deleted === [];
|
||||
$detail = $cause->getMessage()
|
||||
. ($this->deleted !== [] ? '; already deleted: ' . implode(', ', $this->deleted) : '')
|
||||
. ($problems !== [] ? '; rollback: ' . implode('; ', $problems) : '');
|
||||
|
||||
throw new WriteFailed(
|
||||
'Das Menü konnte nicht gespeichert werden.',
|
||||
$rolledBack,
|
||||
null,
|
||||
null,
|
||||
$cause,
|
||||
$detail,
|
||||
sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId),
|
||||
'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit-menu-get prüfen und ihn ggf. '
|
||||
. 'mit lit-menu-set-items anhand von previous_items (Stand vorher) wiederherstellen; entfernte '
|
||||
. 'Einträge müssen dabei neu angelegt werden. Dem Nutzer Bescheid geben.',
|
||||
['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))]
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> The item's stored data, so a rewrite keeps everything. */
|
||||
private function argsFromItem(object $item): array
|
||||
{
|
||||
$isCustom = (string) $item->type === 'custom';
|
||||
|
||||
return [
|
||||
'menu-item-object-id' => (int) $item->object_id,
|
||||
'menu-item-object' => (string) $item->object,
|
||||
'menu-item-type' => (string) $item->type,
|
||||
'menu-item-title' => (string) $item->post_title,
|
||||
'menu-item-url' => $isCustom ? (string) $item->url : '',
|
||||
'menu-item-description' => (string) $item->description,
|
||||
'menu-item-attr-title' => (string) $item->attr_title,
|
||||
'menu-item-target' => (string) $item->target,
|
||||
'menu-item-classes' => implode(' ', array_filter((array) $item->classes)),
|
||||
'menu-item-xfn' => (string) $item->xfn,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node Validated new node.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function argsFromNode(array $node): array
|
||||
{
|
||||
$objectId = (int) ($node['object_id'] ?? 0);
|
||||
|
||||
return match ($node['type']) {
|
||||
'post' => [
|
||||
'menu-item-type' => 'post_type',
|
||||
'menu-item-object' => (string) get_post_type($objectId),
|
||||
'menu-item-object-id' => $objectId,
|
||||
],
|
||||
'taxonomy' => [
|
||||
'menu-item-type' => 'taxonomy',
|
||||
'menu-item-object' => (string) get_term($objectId)->taxonomy,
|
||||
'menu-item-object-id' => $objectId,
|
||||
],
|
||||
default => ['menu-item-type' => 'custom', 'menu-item-object' => 'custom', 'menu-item-url' => ''],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -73,6 +76,7 @@ final class WordPressIntegration implements Integration
|
||||
new MenuListAbility(),
|
||||
new MenuGetAbility(),
|
||||
new MenuCreateAbility($this->log),
|
||||
new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log),
|
||||
new MenuAssignLocationAbility($this->log),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user