MenuWriter::fail guards wp_delete_post for created items: the touched items are still restored and the client gets partial_write (with previous_items) instead of internal_error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
523 lines
19 KiB
PHP
523 lines
19 KiB
PHP
<?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. Only items whose
|
||
* parent, position, title or url actually change are rewritten (and counted as updated).
|
||
*
|
||
* 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'];
|
||
/** Statuses WordPress still shows in menus although visitors get a dead link until publication. */
|
||
private const UNPUBLISHED = ['draft', 'pending', 'future'];
|
||
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 = [];
|
||
|
||
/** Set by plan(): links to draft/pending/future posts are allowed. */
|
||
private bool $allowUnpublished = false;
|
||
|
||
/**
|
||
* @param list<mixed> $tree
|
||
* @param array<int, object> $current Current items by ID.
|
||
* @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning).
|
||
* @return array{
|
||
* errors: array<string, string>,
|
||
* removals: list<array{id: int, title: string}>,
|
||
* warnings: list<string>
|
||
* }
|
||
*/
|
||
public function plan(array $tree, array $current, bool $allowUnpublished = false): array
|
||
{
|
||
$errors = [];
|
||
$warnings = [];
|
||
$seen = [];
|
||
$this->allowUnpublished = $allowUnpublished;
|
||
$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.';
|
||
}
|
||
return $this->checkStatus($post, $warnings);
|
||
case 'taxonomy':
|
||
$term = is_int($objectId) ? get_term($objectId) : null;
|
||
// Terms of non-public taxonomies would show internal structure in a public menu.
|
||
return $term instanceof WP_Term && is_taxonomy_viewable($term->taxonomy)
|
||
? null
|
||
: 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie 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.';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WordPress shows menu items of draft/pending/private/future posts on the live site right
|
||
* away (only trashed or missing objects are hidden), so private posts are always refused
|
||
* and unpublished ones only with allow_unpublished.
|
||
*
|
||
* @param list<string> $warnings
|
||
*/
|
||
private function checkStatus(WP_Post $post, array &$warnings): ?string
|
||
{
|
||
$status = (string) $post->post_status;
|
||
if ($status === 'publish') {
|
||
return null;
|
||
}
|
||
|
||
if ($status === 'private') {
|
||
return 'Private Inhalte dürfen nicht in öffentlichen Menüs erscheinen (Besucher sähen Titel und Link).';
|
||
}
|
||
|
||
if (! in_array($status, self::UNPUBLISHED, true)) {
|
||
return sprintf('Einträge mit Status %s können nicht verlinkt werden.', $status);
|
||
}
|
||
|
||
if (! $this->allowUnpublished) {
|
||
return sprintf(
|
||
'"%s" ist nicht veröffentlicht (%s), würde aber sofort im Menü erscheinen – als toter Link. '
|
||
. 'Erst veröffentlichen oder allow_unpublished: true setzen.',
|
||
$post->post_title,
|
||
$status
|
||
);
|
||
}
|
||
|
||
$warnings[] = sprintf(
|
||
'Eintrag "%s" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.',
|
||
$post->post_title
|
||
);
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @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\x00-\x1f\x7f]/', $url) === 1) {
|
||
return false;
|
||
}
|
||
|
||
if (str_starts_with($url, '/')) {
|
||
// "//host" and "/\host" are protocol-relative links to another host in browsers.
|
||
return ! in_array(substr($url, 1, 1), ['/', '\\'], true);
|
||
}
|
||
|
||
if (str_starts_with($url, '#')) {
|
||
return true;
|
||
}
|
||
|
||
$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;
|
||
if ($existing !== null) {
|
||
$itemId = (int) $existing->ID;
|
||
$seen[$itemId] = true;
|
||
$titleChanges = $this->titleChanges($node, $existing);
|
||
$urlChanges = $this->customUrlChanges($node, $existing);
|
||
$moved = $parentId !== (int) $existing->menu_item_parent
|
||
|| $position !== $this->storedPosition($current, $itemId);
|
||
if ($titleChanges || $urlChanges || $moved) {
|
||
$args = $this->withNode(
|
||
$this->argsFromItem($existing),
|
||
$node,
|
||
$titleChanges,
|
||
$urlChanges,
|
||
$parentId,
|
||
$position
|
||
);
|
||
$this->save($menuId, $itemId, $args);
|
||
$this->touched[] = $itemId;
|
||
}
|
||
} else {
|
||
$args = $this->withNode(
|
||
$this->argsFromNode($node),
|
||
$node,
|
||
isset($node['title']),
|
||
$node['type'] === 'custom',
|
||
$parentId,
|
||
$position
|
||
);
|
||
$itemId = $this->save($menuId, 0, $args);
|
||
$this->created[] = $itemId;
|
||
}
|
||
|
||
$this->writeNodes($menuId, (array) ($node['children'] ?? []), $itemId, $current, $position, $seen);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $args
|
||
* @param array<string, mixed> $node
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function withNode(
|
||
array $args,
|
||
array $node,
|
||
bool $setTitle,
|
||
bool $setUrl,
|
||
int $parentId,
|
||
int $position
|
||
): array {
|
||
if ($setTitle) {
|
||
$args['menu-item-title'] = (string) $node['title'];
|
||
}
|
||
if ($setUrl) {
|
||
$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';
|
||
|
||
return $args;
|
||
}
|
||
|
||
/**
|
||
* The item's current position: its menu_order, or – when that is missing – its index in
|
||
* $current (which is in menu order).
|
||
*
|
||
* @param array<int, object> $current
|
||
*/
|
||
private function storedPosition(array $current, int $itemId): int
|
||
{
|
||
$order = (int) ($current[$itemId]->menu_order ?? 0);
|
||
|
||
return $order > 0 ? $order : (int) array_search($itemId, array_keys($current), true) + 1;
|
||
}
|
||
|
||
/** @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) {
|
||
// Ruling S50: a throwing delete (e.g. a hook) must not stop the rest of the rollback.
|
||
try {
|
||
if (! wp_delete_post($itemId, true) instanceof WP_Post) {
|
||
$problems[] = sprintf('created item %d not deleted', $itemId);
|
||
}
|
||
} catch (Throwable $e) {
|
||
$problems[] = sprintf('created item %d not deleted: %s', $itemId, $e->getMessage());
|
||
}
|
||
}
|
||
|
||
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'] = $this->storedPosition($current, $itemId);
|
||
$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) : '');
|
||
|
||
$reason = 'Das Menü konnte nicht gespeichert werden.';
|
||
$details = ['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))];
|
||
if ($rolledBack) {
|
||
throw WriteFailed::rolledBack($reason, $cause, $detail, details: $details);
|
||
}
|
||
|
||
throw WriteFailed::partial(
|
||
$reason,
|
||
$cause,
|
||
$detail,
|
||
state: sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId),
|
||
hint: 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit/menu-get prüfen und ihn '
|
||
. 'ggf. mit lit/menu-set-items (beide über mcp-adapter-execute-ability) anhand von previous_items '
|
||
. '(Stand vorher) wiederherstellen; entfernte Einträge müssen dabei neu angelegt werden. Dem Nutzer '
|
||
. 'Bescheid geben.',
|
||
details: $details
|
||
);
|
||
}
|
||
|
||
/** @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' => ''],
|
||
};
|
||
}
|
||
}
|