'object', 'properties' => array_merge([ 'post_id' => ['type' => 'integer', 'minimum' => 1], 'rev' => ['type' => 'string', 'description' => 'rev aus lit-content-get (optimistische Sperre).'], 'title' => ['type' => 'string', 'minLength' => 1], ], PostWriter::inputProperties()), 'required' => ['post_id', 'rev'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema(); } public function annotations(): array { return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; } public function isDirect(): bool { return true; } public function permission(array $input): bool|WP_Error { $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); return $post instanceof WP_Error ? $post : true; } public function execute(array $input): mixed { $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); if ($post instanceof WP_Error) { return $post; } if (! Rev::matches($input, $post)) { return Errors::revMismatch(); } if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) { return Errors::unsupported( 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.', 'Divi-Struktur über lit-divi-outline und die lit-divi-*-Tools bearbeiten; Titel, Auszug, ' . 'Felder und Begriffe hier ohne content ändern.' ); } $data = $this->writer->postData($input); if ($data === [] && ! $this->writer->hasExtras($input)) { return Errors::invalidInput( 'Nichts zu ändern.', [], 'Mindestens eines angeben: ' . implode(', ', self::CHANGE_KEYS) . '.' ); } $postType = (string) $post->post_type; $prepared = $this->writer->prepare($postType, $input, false); if ($prepared instanceof WP_Error) { return $prepared; } $postId = (int) $post->ID; $snapshot = $this->writer->snapshot($postId, $postType, $prepared, $input); $before = $this->columnsBefore($post, $data); $revisionId = RevisionPoint::capture($postId); $rowTouched = false; try { if ($data !== []) { // Set before the call: a throwing save_post hook runs after the row was written. $rowTouched = true; $result = wp_update_post(wp_slash(array_merge($data, ['ID' => $postId])), true); if ($result instanceof WP_Error) { return Errors::invalidInput( 'WordPress hat die Änderung abgelehnt: ' . $result->get_error_message() ); } } $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { $this->rollBack($post, $rowTouched ? $before : [], $revisionId, $snapshot, $e); } return $this->result($post, $input, $prepared['warnings'], $revisionId); } /** * @param array $input * @param list $warnings * @return array */ private function result(WP_Post $post, array $input, array $warnings, ?int $revisionId): array { $postId = (int) $post->ID; $postType = (string) $post->post_type; // The change is saved – failures from here on are logged and reported as warnings only. FollowUp::run( fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), $this->name() . ' lit_mcp_after_save', 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', $warnings ); $updated = get_post($postId); $updated = $updated instanceof WP_Post ? $updated : $post; $changedKeys = array_keys(array_intersect_key($input, array_flip(self::CHANGE_KEYS))); $summary = sprintf('"%s" geändert: %s', $updated->post_title, implode(', ', $changedKeys)); $logId = FollowUp::run( fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary, $revisionId), $this->name() . ' change log', 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', $warnings ); return ToolResult::ok(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status)) ->changed((string) $postId, $postType, 'updated', (string) $updated->post_title) ->rev(Rev::ofPost($updated)) ->revisionId($revisionId) ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) ->warnings($warnings) ->toArray(); } /** * Values of the columns about to be written, read before the update (the template * lives in post meta, so it must be read now – afterwards it is the new one). * * @param array $data * @return array */ private function columnsBefore(WP_Post $post, array $data): array { $before = []; foreach (array_keys($data) as $column) { $before[$column] = $column === 'page_template' ? ((string) get_page_template_slug($post) ?: PostWriter::DEFAULT_TEMPLATE) : $post->{$column}; } return $before; } /** * @param array $before Pre-update values of the columns that were (possibly) written. * @param array $snapshot Output of PostWriter::snapshot(). * @throws WriteFailed Always – rolledBack tells whether everything was restored. */ private function rollBack(WP_Post $post, array $before, ?int $revisionId, array $snapshot, Throwable $cause): never { $postId = (int) $post->ID; $steps = [ fn () => $this->writer->deleteCreatedTerms(), fn () => $this->writer->restore($postId, (string) $post->post_type, $snapshot), fn () => $this->restoreRow($post, $before, $revisionId), ]; $failures = []; foreach ($steps as $step) { try { $step(); } catch (Throwable $e) { $failures[] = $e->getMessage(); } } $detail = sprintf('Update of post %d failed: %s', $postId, $cause->getMessage()); if ($failures === []) { throw new WriteFailed(self::REASON, true, $postId, $revisionId, $cause, $detail); } $this->log->recordPartial( $this->name(), (string) $post->post_type, (string) $postId, sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), $revisionId ); throw new WriteFailed( self::REASON, false, $postId, $revisionId, $cause, $detail . '; rollback incomplete: ' . implode('; ', $failures) ); } /** * Title, content and excerpt come back from the restore-point revision when that * revision reflects the pre-update state (revisions enabled: RevisionPoint then * saved or confirmed it). Everything else – and everything when revisions are off, * where the newest revision may be stale – gets the value read before the update. * * @param array $before * @throws RuntimeException */ private function restoreRow(WP_Post $post, array $before, ?int $revisionId): void { if ($before === []) { return; } $fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED)); if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { $restored = wp_restore_post_revision($revisionId, $fromRevision); if ($restored && ! is_wp_error($restored)) { $before = array_diff_key($before, array_flip($fromRevision)); } } if ($before === []) { return; } $result = wp_update_post(wp_slash(array_merge($before, ['ID' => (int) $post->ID])), true); if ($result instanceof WP_Error || ! $result) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; throw new RuntimeException('Post row could not be restored: ' . $message); } } }