feat: draft copies and replace-with-copy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Copies entries (draft copy) and swaps a copy's content back into its original.
|
||||
* Meta is copied raw – Divi flags (_et_pb_*), page template (_wp_page_template),
|
||||
* featured image (_thumbnail_id) and Pods meta included; Pods relationships stored in
|
||||
* the meta table travel with it. Only per-post bookkeeping (SKIP_META) stays put.
|
||||
* snapshot()/restore() let lit/post-replace-with-copy undo a failed swap.
|
||||
* Not final so abilities can be tested with a mock.
|
||||
*/
|
||||
class PostCloner
|
||||
{
|
||||
public const COPY_OF_META = '_lit_mcp_copy_of';
|
||||
|
||||
public const COPY_SUFFIX = ' (Kopie)';
|
||||
|
||||
/** Bookkeeping meta that belongs to one post and must never be copied or cleared. */
|
||||
private const SKIP_META = [
|
||||
'_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', '_wp_trash_meta_status',
|
||||
'_wp_trash_meta_time', '_wp_desired_post_slug', '_encloseme', '_pingme', self::COPY_OF_META,
|
||||
];
|
||||
|
||||
private const DUPLICATE_REASON = 'Die Kopie konnte nicht vollständig angelegt werden.';
|
||||
|
||||
/**
|
||||
* Creates "<title> (Kopie)" as draft of the current user. All or nothing: if meta or
|
||||
* terms fail after the insert, the new draft is deleted again.
|
||||
*
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
public function duplicate(WP_Post $original): int
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$copyId = wp_insert_post(wp_slash([
|
||||
'post_type' => (string) $original->post_type,
|
||||
'post_status' => 'draft',
|
||||
'post_title' => (string) $original->post_title . self::COPY_SUFFIX,
|
||||
'post_content' => (string) $original->post_content,
|
||||
'post_excerpt' => (string) $original->post_excerpt,
|
||||
'post_parent' => (int) $original->post_parent,
|
||||
'menu_order' => (int) $original->menu_order,
|
||||
'comment_status' => (string) $original->comment_status,
|
||||
'ping_status' => (string) $original->ping_status,
|
||||
'post_password' => (string) $original->post_password,
|
||||
'post_author' => get_current_user_id(),
|
||||
]), true);
|
||||
|
||||
if ($copyId instanceof WP_Error || (int) $copyId === 0) {
|
||||
$reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown error';
|
||||
throw new WriteFailed(
|
||||
'Die Kopie konnte nicht angelegt werden.',
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sprintf('Copy of post %d could not be created: %s', $originalId, $reason)
|
||||
);
|
||||
}
|
||||
|
||||
$copyId = (int) $copyId;
|
||||
try {
|
||||
$this->replaceMeta($copyId, $this->ownMeta($originalId));
|
||||
$this->setTerms($copyId, $this->termIds($original));
|
||||
if (update_post_meta($copyId, self::COPY_OF_META, $originalId) === false) {
|
||||
throw new RuntimeException('Copy marker could not be written.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$deleted = wp_delete_post($copyId, true);
|
||||
$detail = sprintf('Copy %d of post %d failed: %s', $copyId, $originalId, $e->getMessage());
|
||||
if ($deleted) {
|
||||
throw new WriteFailed(self::DUPLICATE_REASON, true, null, null, $e, $detail . '; draft deleted');
|
||||
}
|
||||
|
||||
throw new WriteFailed(self::DUPLICATE_REASON, false, $copyId, null, $e, $detail);
|
||||
}
|
||||
|
||||
return $copyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy
|
||||
* into the original. Status, slug, date and author of the original stay.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function replace(WP_Post $original, WP_Post $copy): void
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$this->updateRow($originalId, [
|
||||
'post_title' => self::withoutSuffix((string) $copy->post_title),
|
||||
'post_content' => (string) $copy->post_content,
|
||||
'post_excerpt' => (string) $copy->post_excerpt,
|
||||
]);
|
||||
$this->replaceMeta($originalId, $this->ownMeta((int) $copy->ID));
|
||||
$this->setTerms($originalId, $this->termIds($copy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything replace() overwrites – call before replace().
|
||||
*
|
||||
* @return array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function snapshot(WP_Post $post): array
|
||||
{
|
||||
return [
|
||||
'post' => [
|
||||
'post_title' => (string) $post->post_title,
|
||||
'post_content' => (string) $post->post_content,
|
||||
'post_excerpt' => (string) $post->post_excerpt,
|
||||
],
|
||||
'meta' => $this->ownMeta((int) $post->ID),
|
||||
'terms' => $this->termIds($post),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a snapshot back. Tries every part even if one fails.
|
||||
*
|
||||
* @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
|
||||
* $snapshot Output of snapshot().
|
||||
* @throws RuntimeException Listing every part that could not be restored.
|
||||
*/
|
||||
public function restore(WP_Post $post, array $snapshot): void
|
||||
{
|
||||
$postId = (int) $post->ID;
|
||||
$steps = [
|
||||
fn () => $this->updateRow($postId, $snapshot['post']),
|
||||
fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
||||
fn () => $this->setTerms($postId, $snapshot['terms']),
|
||||
];
|
||||
$failures = [];
|
||||
foreach ($steps as $step) {
|
||||
try {
|
||||
$step();
|
||||
} catch (Throwable $e) {
|
||||
$failures[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures !== []) {
|
||||
throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures));
|
||||
}
|
||||
}
|
||||
|
||||
public static function withoutSuffix(string $title): string
|
||||
{
|
||||
return (string) preg_replace('/ \(Kopie\)$/u', '', $title);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $columns
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function updateRow(int $postId, array $columns): void
|
||||
{
|
||||
$result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true);
|
||||
if ($result instanceof WP_Error || ! $result) {
|
||||
$message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error';
|
||||
throw new RuntimeException(sprintf('Post %d could not be updated: %s', $postId, $message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw meta of a post without SKIP_META (values as stored, serialized ones as strings).
|
||||
*
|
||||
* @return array<string, list<mixed>>
|
||||
*/
|
||||
private function ownMeta(int $postId): array
|
||||
{
|
||||
$meta = [];
|
||||
foreach ((array) get_post_meta($postId) as $key => $values) {
|
||||
if (! in_array((string) $key, self::SKIP_META, true)) {
|
||||
$meta[(string) $key] = array_values((array) $values);
|
||||
}
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the post's meta (except SKIP_META) exactly $meta.
|
||||
*
|
||||
* @param array<string, list<mixed>> $meta
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function replaceMeta(int $postId, array $meta): void
|
||||
{
|
||||
foreach (array_keys($this->ownMeta($postId)) as $key) {
|
||||
if (delete_post_meta($postId, $key) === false) {
|
||||
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($meta as $key => $values) {
|
||||
foreach ($values as $value) {
|
||||
if (add_post_meta($postId, $key, wp_slash(maybe_unserialize($value))) === false) {
|
||||
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be written.', $key, $postId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<int>> Taxonomy => term IDs, for every taxonomy of the post type.
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function termIds(WP_Post $post): array
|
||||
{
|
||||
$terms = [];
|
||||
foreach (get_object_taxonomies((string) $post->post_type) as $taxonomy) {
|
||||
$ids = wp_get_object_terms((int) $post->ID, $taxonomy, ['fields' => 'ids']);
|
||||
if ($ids instanceof WP_Error) {
|
||||
throw new RuntimeException(
|
||||
sprintf('Terms (%s) could not be read: %s', $taxonomy, $ids->get_error_message())
|
||||
);
|
||||
}
|
||||
$terms[(string) $taxonomy] = array_map('intval', (array) $ids);
|
||||
}
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<int>> $terms
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function setTerms(int $postId, array $terms): void
|
||||
{
|
||||
foreach ($terms as $taxonomy => $ids) {
|
||||
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
|
||||
if ($result instanceof WP_Error) {
|
||||
throw new RuntimeException(
|
||||
sprintf('Terms (%s) could not be set: %s', $taxonomy, $result->get_error_message())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* lit/post-duplicate: creates a draft copy of an entry (spec §6.2). The original –
|
||||
* ID, URL, menu items, content – is not touched. A failure after the insert deletes
|
||||
* the new draft again (PostCloner throws WriteFailed → AbilityRegistrar).
|
||||
*/
|
||||
final class PostDuplicateAbility implements Ability
|
||||
{
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PostCloner $cloner,
|
||||
private ChangeLog $log
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/post-duplicate';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Entwurfskopie anlegen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt eine Entwurfskopie eines Eintrags an (Inhalt, Felder/Meta, Begriffe, Beitragsbild, Template, '
|
||||
. 'Divi-Einstellungen). Typischer Ablauf: Kopie bearbeiten, prüfen, dann mit '
|
||||
. 'lit-post-replace-with-copy ins Original übernehmen – so bleibt die veröffentlichte Seite bis dahin '
|
||||
. 'unverändert. Die Antwort enthält die rev der Kopie für lit-content-update.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['post_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema(['original_id' => ['type' => 'integer']], ['original_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$post = $this->load($input);
|
||||
|
||||
return $post instanceof WP_Error ? $post : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$original = $this->load($input);
|
||||
if ($original instanceof WP_Error) {
|
||||
return $original;
|
||||
}
|
||||
|
||||
$copyId = $this->cloner->duplicate($original);
|
||||
|
||||
// The copy is saved – failures from here on are logged and reported as warnings only.
|
||||
$warnings = [];
|
||||
FollowUp::run(
|
||||
fn () => do_action('lit_mcp_after_save', $copyId, $this->name(), $input),
|
||||
$this->name() . ' lit_mcp_after_save',
|
||||
'Eine Erweiterung ist nach dem Anlegen der Kopie auf einen Fehler gestoßen; die Kopie ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
$copy = get_post($copyId);
|
||||
$copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : '';
|
||||
$postType = (string) $original->post_type;
|
||||
$summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID);
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), $postType, (string) $copyId, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; die Kopie ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId))
|
||||
->changed((string) $copyId, $postType, 'created', $copyTitle)
|
||||
->rev($copy instanceof WP_Post ? Rev::ofPost($copy) : null)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw'))
|
||||
->warnings($warnings)
|
||||
->with('original_id', (int) $original->ID)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function load(array $input): WP_Post|WP_Error
|
||||
{
|
||||
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
|
||||
if ($post instanceof WP_Error) {
|
||||
return $post;
|
||||
}
|
||||
|
||||
$type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
|
||||
|
||||
return $type instanceof WP_Error ? $type : $post;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
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 Throwable;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* lit/post-replace-with-copy: swaps a copy made by lit/post-duplicate into its original
|
||||
* (spec §6.2). ID, URL, status and menu items of the original stay. rev of the original
|
||||
* (from lit/content-get) is required so parallel edits are never overwritten. All or
|
||||
* nothing (spec §8.2): the original's title/content/excerpt, meta and terms are
|
||||
* snapshotted and restored if the swap fails (WriteFailed → lit_write_failed, or
|
||||
* lit_partial_write when the restore was incomplete). A revision is taken first. The
|
||||
* copy then goes to the trash – refused up front when the trash is disabled, because
|
||||
* wp_trash_post() would delete permanently then.
|
||||
*/
|
||||
final class PostReplaceWithCopyAbility implements Ability
|
||||
{
|
||||
private const REASON = 'Die Kopie konnte nicht ins Original übernommen werden.';
|
||||
|
||||
/** @var callable(): bool */
|
||||
private $trashEnabled;
|
||||
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private PostCloner $cloner,
|
||||
private ChangeLog $log,
|
||||
?callable $trashEnabled = null
|
||||
) {
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/post-replace-with-copy';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Kopie ins Original übernehmen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie '
|
||||
. 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine '
|
||||
. 'Revision des Originals, die Kopie kommt danach in den Papierkorb. rev des Originals aus '
|
||||
. 'lit-content-get ist Pflicht. Erst nach Rückfrage mit confirm: true aufrufen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'original_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'copy_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'rev' => [
|
||||
'type' => 'string',
|
||||
'description' => 'rev des Originals aus lit-content-get (optimistische Sperre).',
|
||||
],
|
||||
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
|
||||
],
|
||||
'required' => ['original_id', 'copy_id', 'rev'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema();
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$pair = $this->load($input);
|
||||
|
||||
return $pair instanceof WP_Error ? $pair : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$pair = $this->load($input);
|
||||
if ($pair instanceof WP_Error) {
|
||||
return $pair;
|
||||
}
|
||||
|
||||
[$original, $copy] = $pair;
|
||||
$refusal = $this->refusal($original, $copy, $input);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$originalId = (int) $original->ID;
|
||||
try {
|
||||
$snapshot = $this->cloner->snapshot($original);
|
||||
} catch (Throwable $e) {
|
||||
throw new WriteFailed(self::REASON, true, $originalId, null, $e);
|
||||
}
|
||||
|
||||
$revisionId = RevisionPoint::capture($originalId);
|
||||
try {
|
||||
$this->cloner->replace($original, $copy);
|
||||
} catch (Throwable $e) {
|
||||
$this->rollBack($original, $snapshot, $revisionId, $e);
|
||||
}
|
||||
|
||||
return $this->result($original, $copy, $input, $revisionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that need no write, in the order a client should fix them.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
private function refusal(WP_Post $original, WP_Post $copy, array $input): ?WP_Error
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$copyId = (int) $copy->ID;
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(sprintf(
|
||||
'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?',
|
||||
$original->post_title,
|
||||
$originalId,
|
||||
$copyId
|
||||
));
|
||||
}
|
||||
|
||||
$copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true);
|
||||
if ($copy->post_type !== $original->post_type || $copyOf !== $originalId || $copyId === $originalId) {
|
||||
return Errors::invalidInput(
|
||||
'Die Kopie gehört nicht zu diesem Original.',
|
||||
['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)],
|
||||
'Kopie mit lit-post-duplicate aus diesem Original erstellen.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($copy->post_status === 'trash') {
|
||||
return Errors::invalidInput(
|
||||
'Die Kopie liegt im Papierkorb.',
|
||||
['copy_id' => 'Kopie liegt im Papierkorb'],
|
||||
'Kopie in WordPress wiederherstellen oder mit lit-post-duplicate eine neue Kopie erstellen.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($original)) {
|
||||
return Errors::revMismatch();
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
|
||||
. 'Nichts wurde geändert.',
|
||||
'Den Austausch in WordPress manuell durchführen.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId): array
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$copyId = (int) $copy->ID;
|
||||
$postType = (string) $original->post_type;
|
||||
|
||||
// The swap is saved – failures from here on are reported as warnings only (ruling P7).
|
||||
$warnings = [];
|
||||
$trashWarning = sprintf('Kopie %d konnte nicht in den Papierkorb gelegt werden', $copyId);
|
||||
$ignored = [];
|
||||
$trashed = (bool) FollowUp::run(
|
||||
static fn () => wp_trash_post($copyId),
|
||||
$this->name() . ' trash copy',
|
||||
$trashWarning,
|
||||
$ignored
|
||||
);
|
||||
if (! $trashed) {
|
||||
$warnings[] = $trashWarning;
|
||||
}
|
||||
|
||||
FollowUp::run(
|
||||
fn () => do_action('lit_mcp_after_save', $originalId, $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
|
||||
);
|
||||
|
||||
$title = PostCloner::withoutSuffix((string) $copy->post_title);
|
||||
$summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId);
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
$updated = get_post($originalId);
|
||||
$updated = $updated instanceof WP_Post ? $updated : $original;
|
||||
$statusNote = sprintf(
|
||||
'; Status bleibt %s, %s.',
|
||||
$original->post_status,
|
||||
$trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter'
|
||||
);
|
||||
|
||||
$result = ToolResult::ok($summary . $statusNote)
|
||||
->changed((string) $originalId, $postType, 'replaced', $title);
|
||||
if ($trashed) {
|
||||
$result->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title);
|
||||
}
|
||||
|
||||
return $result
|
||||
->rev(Rev::ofPost($updated))
|
||||
->revisionId($revisionId)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw'))
|
||||
->warnings($warnings)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
|
||||
* $snapshot Output of PostCloner::snapshot().
|
||||
* @throws WriteFailed Always – rolledBack tells whether the original was restored completely.
|
||||
*/
|
||||
private function rollBack(WP_Post $original, array $snapshot, ?int $revisionId, Throwable $cause): never
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage());
|
||||
try {
|
||||
$this->cloner->restore($original, $snapshot);
|
||||
} catch (Throwable $e) {
|
||||
$ignored = [];
|
||||
FollowUp::run(
|
||||
fn () => $this->log->record(
|
||||
$this->name(),
|
||||
(string) $original->post_type,
|
||||
(string) $originalId,
|
||||
sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title),
|
||||
$revisionId,
|
||||
null,
|
||||
'partial'
|
||||
),
|
||||
$this->name() . ' change log',
|
||||
'',
|
||||
$ignored
|
||||
);
|
||||
throw new WriteFailed(
|
||||
self::REASON,
|
||||
false,
|
||||
$originalId,
|
||||
$revisionId,
|
||||
$cause,
|
||||
$detail . '; rollback incomplete: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
|
||||
*/
|
||||
private function load(array $input): array|WP_Error
|
||||
{
|
||||
$original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id');
|
||||
if ($original instanceof WP_Error) {
|
||||
return $original;
|
||||
}
|
||||
|
||||
$copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id');
|
||||
if ($copy instanceof WP_Error) {
|
||||
return $copy;
|
||||
}
|
||||
|
||||
if (! current_user_can('delete_post', (int) $copy->ID)) {
|
||||
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
|
||||
}
|
||||
|
||||
return [$original, $copy];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user