Files
WP-LIT-MCP/src/Integrations/WordPress/Menus/MenuWriter.php
T
janniclas.loosenandClaude Opus 5.5 267e5d2076 fix: menu rollback survives a throwing delete of a new item (S50)
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
2026-09-26 18:35:21 +02:00

523 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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' => ''],
};
}
}