Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
166 lines
5.7 KiB
PHP
166 lines
5.7 KiB
PHP
<?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;
|
||
}
|
||
}
|