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:
2026-09-26 00:14:25 +02:00
co-authored by Claude Opus 5.5
parent 954f683dd2
commit f80cfb9e44
11 changed files with 1357 additions and 9 deletions
@@ -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;
}
}