Files
WP-LIT-MCP/src/Integrations/WordPress/Content/ContentUpdateAbility.php
T

301 lines
11 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\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
/**
* lit/content-update: changes an entry without touching its status. rev (from
* lit/content-get) is required so parallel edits are never overwritten (spec §2
* principle 7). All or nothing (spec §8.2): input is validated first; if fields,
* terms or the featured image fail after the post row was updated, the row is
* restored from the restore-point revision (plus old values for columns revisions
* do not cover), the extras get their old values back, created terms are deleted, and
* a WriteFailed is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write
* when the rollback was incomplete; partial writes are logged with result "partial").
*/
final class ContentUpdateAbility implements Ability
{
private const CHANGE_KEYS = [
'title', 'content', 'excerpt', 'slug', 'parent', 'menu_order', 'template', 'terms', 'fields', 'featured_image',
];
private const REASON = 'Felder, Begriffe, Beitragsbild oder Kerndaten konnten nicht gespeichert werden.';
/** Post columns a revision stores (and wp_restore_post_revision() restores). */
private const REVISIONED = ['post_title', 'post_content', 'post_excerpt'];
public function __construct(
private ContentSchema $schema,
private PostWriter $writer,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/content-update';
}
public function label(): string
{
return 'Inhalt ändern';
}
public function description(): string
{
return 'Ändert Kerndaten, Begriffe, Felder oder Beitragsbild eines Eintrags; der Status bleibt '
. 'unverändert (veröffentlichte Inhalte werden live geändert, vorher entsteht eine Revision). '
. 'rev aus lit-content-get ist Pflicht, damit parallele Änderungen nicht überschrieben werden; '
. 'die Antwort enthält die neue rev für weitere Änderungen. '
. 'content von Divi-Seiten ist hier nicht änderbar.';
}
public function inputSchema(): array
{
return [
'type' => '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<string, mixed> $input
* @param list<string> $warnings
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $before Pre-update values of the columns that were (possibly) written.
* @param array<string, mixed> $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<string, mixed> $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);
}
}
}