Items created by the running apply(). */ private array $created = []; /** @var list Existing items rewritten by the running apply(). */ private array $touched = []; /** @var list 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 $tree * @param array $current Current items by ID. * @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning). * @return array{ * errors: array, * removals: list, * warnings: list * } */ 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> $tree Validated by plan(). * @param array $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 $current * @param array $seen * @param array $errors * @param list $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 $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 $node * @param array $current * @param array $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 $node * @param list $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 $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 $node */ private function urlChanges(array $node, object $item): bool { return array_key_exists('url', $node) && $node['url'] !== (string) $item->url; } /** @param array $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> $nodes * @param array $current * @param array $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 $args * @param array $node * @return array */ 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 $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 $node */ private function customUrlChanges(array $node, object $item): bool { return (string) $item->type === 'custom' && $this->urlChanges($node, $item); } /** * @param array $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 $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 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 $node Validated new node. * @return array */ 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' => ''], }; } }