fix: trashed backup before replace, byte-exact meta copy, partial rollback
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:
@@ -11,10 +11,12 @@ 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.
|
||||
* Copies entries (draft copy, trashed backup) and swaps a copy's content back into its
|
||||
* original. Meta is copied byte-exact: the stored meta_value strings are read and
|
||||
* inserted directly via $wpdb, so nothing is unserialized, re-serialized or unslashed on
|
||||
* the way – 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.
|
||||
*/
|
||||
@@ -22,15 +24,25 @@ class PostCloner
|
||||
{
|
||||
public const COPY_OF_META = '_lit_mcp_copy_of';
|
||||
|
||||
public const BACKUP_OF_META = '_lit_mcp_backup_of';
|
||||
|
||||
public const COPY_SUFFIX = ' (Kopie)';
|
||||
|
||||
public const BACKUP_SUFFIX = ' (Sicherung)';
|
||||
|
||||
/** Parts of a post replace()/restore() write. */
|
||||
public const PARTS = ['post', 'meta', 'terms'];
|
||||
|
||||
/** 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,
|
||||
self::BACKUP_OF_META,
|
||||
];
|
||||
|
||||
private const DUPLICATE_REASON = 'Die Kopie konnte nicht vollständig angelegt werden.';
|
||||
public function __construct(private ?\wpdb $db = null)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates "<title> (Kopie)" as draft of the current user. All or nothing: if meta or
|
||||
@@ -40,75 +52,63 @@ class PostCloner
|
||||
*/
|
||||
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);
|
||||
return $this->cloneAsDraft($original, self::COPY_SUFFIX, self::COPY_OF_META, 'Die Kopie');
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Creates "<title> (Sicherung)" – a full clone of the post – and moves it to the trash.
|
||||
* If either step fails, the backup is deleted again. Call only when the trash is enabled.
|
||||
*
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
public function backup(WP_Post $original): int
|
||||
{
|
||||
$backupId = $this->cloneAsDraft($original, self::BACKUP_SUFFIX, self::BACKUP_OF_META, 'Die Sicherung');
|
||||
|
||||
$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.');
|
||||
$trashed = wp_trash_post($backupId);
|
||||
if (! $trashed || get_post_status($backupId) !== 'trash') {
|
||||
throw new RuntimeException('Backup could not be moved to the trash.');
|
||||
}
|
||||
} 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);
|
||||
$this->discard($original, $backupId, $e, 'Die Sicherung konnte nicht in den Papierkorb gelegt werden.');
|
||||
}
|
||||
|
||||
return $copyId;
|
||||
return $backupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @throws ReplaceFailed Naming the parts that were (possibly) written.
|
||||
*/
|
||||
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));
|
||||
$parts = [];
|
||||
try {
|
||||
$parts[] = 'post';
|
||||
$this->updateRow($originalId, [
|
||||
'post_title' => self::withoutSuffix((string) $copy->post_title),
|
||||
'post_content' => (string) $copy->post_content,
|
||||
'post_excerpt' => (string) $copy->post_excerpt,
|
||||
]);
|
||||
$meta = $this->ownMeta((int) $copy->ID);
|
||||
$terms = $this->termIds($copy);
|
||||
$parts[] = 'meta';
|
||||
$this->replaceMeta($originalId, $meta);
|
||||
$parts[] = 'terms';
|
||||
$this->setTerms($originalId, $terms);
|
||||
} catch (Throwable $e) {
|
||||
throw new ReplaceFailed($parts, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything replace() overwrites – call before replace().
|
||||
*
|
||||
* @return array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
|
||||
* @return array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>}
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function snapshot(WP_Post $post): array
|
||||
@@ -125,22 +125,23 @@ class PostCloner
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a snapshot back. Tries every part even if one fails.
|
||||
* Writes the given parts of 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>>}
|
||||
* @param array{post: array<string, string>, meta: array<string, list<?string>>, terms: array<string, list<int>>}
|
||||
* $snapshot Output of snapshot().
|
||||
* @param list<string> $parts Subset of PARTS.
|
||||
* @throws RuntimeException Listing every part that could not be restored.
|
||||
*/
|
||||
public function restore(WP_Post $post, array $snapshot): void
|
||||
public function restore(WP_Post $post, array $snapshot, array $parts = self::PARTS): void
|
||||
{
|
||||
$postId = (int) $post->ID;
|
||||
$steps = [
|
||||
fn () => $this->updateRow($postId, $snapshot['post']),
|
||||
fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
||||
fn () => $this->setTerms($postId, $snapshot['terms']),
|
||||
'post' => fn () => $this->updateRow($postId, $snapshot['post']),
|
||||
'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
||||
'terms' => fn () => $this->setTerms($postId, $snapshot['terms']),
|
||||
];
|
||||
$failures = [];
|
||||
foreach ($steps as $step) {
|
||||
foreach (array_intersect_key($steps, array_flip($parts)) as $step) {
|
||||
try {
|
||||
$step();
|
||||
} catch (Throwable $e) {
|
||||
@@ -158,6 +159,66 @@ class PostCloner
|
||||
return (string) preg_replace('/ \(Kopie\)$/u', '', $title);
|
||||
}
|
||||
|
||||
/** @throws WriteFailed */
|
||||
private function cloneAsDraft(WP_Post $original, string $suffix, string $markerKey, string $label): int
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$cloneId = wp_insert_post(wp_slash([
|
||||
'post_type' => (string) $original->post_type,
|
||||
'post_status' => 'draft',
|
||||
'post_title' => (string) $original->post_title . $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 ($cloneId instanceof WP_Error || (int) $cloneId === 0) {
|
||||
$reason = $cloneId instanceof WP_Error ? $cloneId->get_error_message() : 'unknown error';
|
||||
throw new WriteFailed(
|
||||
$label . ' konnte nicht angelegt werden.',
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sprintf('Clone (%s) of post %d could not be created: %s', $markerKey, $originalId, $reason)
|
||||
);
|
||||
}
|
||||
|
||||
$cloneId = (int) $cloneId;
|
||||
try {
|
||||
$this->replaceMeta($cloneId, $this->ownMeta($originalId));
|
||||
$this->setTerms($cloneId, $this->termIds($original));
|
||||
if (update_post_meta($cloneId, $markerKey, $originalId) === false) {
|
||||
throw new RuntimeException('Clone marker could not be written.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->discard($original, $cloneId, $e, $label . ' konnte nicht vollständig angelegt werden.');
|
||||
}
|
||||
|
||||
return $cloneId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a clone this class just created (never anything else) and reports the failure.
|
||||
*
|
||||
* @throws WriteFailed Always.
|
||||
*/
|
||||
private function discard(WP_Post $original, int $cloneId, Throwable $cause, string $reason): never
|
||||
{
|
||||
$deleted = wp_delete_post($cloneId, true);
|
||||
$detail = sprintf('Clone %d of post %d failed: %s', $cloneId, (int) $original->ID, $cause->getMessage());
|
||||
if ($deleted) {
|
||||
throw new WriteFailed($reason, true, null, null, $cause, $detail . '; clone deleted');
|
||||
}
|
||||
|
||||
throw new WriteFailed($reason, false, $cloneId, null, $cause, $detail . '; clone could not be deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $columns
|
||||
* @throws RuntimeException
|
||||
@@ -172,16 +233,26 @@ class PostCloner
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw meta of a post without SKIP_META (values as stored, serialized ones as strings).
|
||||
* Stored meta of a post without SKIP_META: meta_value strings exactly as in the database.
|
||||
*
|
||||
* @return array<string, list<mixed>>
|
||||
* @return array<string, list<string|null>>
|
||||
*/
|
||||
private function ownMeta(int $postId): array
|
||||
{
|
||||
$db = $this->db();
|
||||
$rows = $db->get_results(
|
||||
$db->prepare(
|
||||
"SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id",
|
||||
$postId
|
||||
),
|
||||
'ARRAY_A'
|
||||
);
|
||||
|
||||
$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);
|
||||
foreach ((array) $rows as $row) {
|
||||
$key = (string) $row['meta_key'];
|
||||
if (! in_array($key, self::SKIP_META, true)) {
|
||||
$meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,25 +260,37 @@ class PostCloner
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the post's meta (except SKIP_META) exactly $meta.
|
||||
* Makes the post's meta (except SKIP_META) exactly $meta; values are inserted as stored.
|
||||
*
|
||||
* @param array<string, list<mixed>> $meta
|
||||
* @param array<string, list<string|null>> $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));
|
||||
$db = $this->db();
|
||||
try {
|
||||
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) {
|
||||
$inserted = $db->insert(
|
||||
$db->postmeta,
|
||||
['post_id' => $postId, 'meta_key' => $key, 'meta_value' => $value],
|
||||
['%d', '%s', '%s']
|
||||
);
|
||||
if ($inserted === false) {
|
||||
throw new RuntimeException(
|
||||
sprintf('Meta "%s" of post %d could not be written.', $key, $postId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
wp_cache_delete($postId, 'post_meta');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,4 +329,14 @@ class PostCloner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function db(): \wpdb
|
||||
{
|
||||
if ($this->db === null) {
|
||||
global $wpdb;
|
||||
$this->db = $wpdb;
|
||||
}
|
||||
|
||||
return $this->db;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -125,6 +126,14 @@ final class PostDuplicateAbility implements Ability
|
||||
return $post;
|
||||
}
|
||||
|
||||
if ($post->post_status === 'trash') {
|
||||
return Errors::invalidInput(
|
||||
'Der Eintrag liegt im Papierkorb.',
|
||||
['post_id' => 'Eintrag liegt im Papierkorb'],
|
||||
'Eintrag zuerst in WordPress wiederherstellen.'
|
||||
);
|
||||
}
|
||||
|
||||
$type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
|
||||
|
||||
return $type instanceof WP_Error ? $type : $post;
|
||||
|
||||
@@ -23,10 +23,12 @@ use WP_Post;
|
||||
* (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.
|
||||
* snapshotted and the parts reached are restored if the swap fails (WriteFailed →
|
||||
* lit_write_failed, or lit_partial_write when the restore was incomplete). Before the
|
||||
* swap, a full clone of the original is put into the trash as backup (ruling P12; meta
|
||||
* and terms are not revisioned) and a revision is taken. The copy then goes to the
|
||||
* trash as well – refused up front when the trash is disabled, because wp_trash_post()
|
||||
* would delete permanently then.
|
||||
*/
|
||||
final class PostReplaceWithCopyAbility implements Ability
|
||||
{
|
||||
@@ -59,8 +61,11 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
{
|
||||
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.';
|
||||
. 'Revision und eine vollständige Sicherung des Originals (im Papierkorb, backup_id), die Kopie kommt '
|
||||
. 'danach in den Papierkorb. Die Kopie muss Entwurf oder ausstehend sein. rev des Originals aus '
|
||||
. 'lit-content-get ist Pflicht; sie deckt nur Titel, Inhalt, Auszug und Status ab – Felder/Meta, die '
|
||||
. 'nach dem Duplizieren direkt am Original geändert wurden, werden überschrieben (stehen aber in der '
|
||||
. 'Sicherung). Erst nach Rückfrage mit confirm: true aufrufen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
@@ -83,7 +88,7 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema();
|
||||
return ToolResult::schema(['backup_id' => ['type' => 'integer']], ['backup_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
@@ -123,14 +128,18 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
throw new WriteFailed(self::REASON, true, $originalId, null, $e);
|
||||
}
|
||||
|
||||
// Throws WriteFailed (backup deleted again) before anything touches the original.
|
||||
$backupId = $this->cloner->backup($original);
|
||||
$revisionId = RevisionPoint::capture($originalId);
|
||||
try {
|
||||
$this->cloner->replace($original, $copy);
|
||||
} catch (ReplaceFailed $e) {
|
||||
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e);
|
||||
} catch (Throwable $e) {
|
||||
$this->rollBack($original, $snapshot, $revisionId, $e);
|
||||
$this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e);
|
||||
}
|
||||
|
||||
return $this->result($original, $copy, $input, $revisionId);
|
||||
return $this->result($original, $copy, $input, $revisionId, $backupId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,11 +169,19 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
);
|
||||
}
|
||||
|
||||
if ($copy->post_status === 'trash') {
|
||||
if ($original->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.'
|
||||
'Das Original liegt im Papierkorb.',
|
||||
['original_id' => 'Original liegt im Papierkorb'],
|
||||
'Original zuerst in WordPress wiederherstellen.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($copy->post_status, ['draft', 'pending'], true)) {
|
||||
return Errors::invalidInput(
|
||||
sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status),
|
||||
['copy_id' => 'Kopie muss Entwurf oder ausstehend sein'],
|
||||
'Mit lit-post-duplicate eine neue Kopie erstellen (bzw. die Kopie in WordPress wiederherstellen).'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,7 +204,7 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId): array
|
||||
private function result(WP_Post $original, WP_Post $copy, array $input, ?int $revisionId, int $backupId): array
|
||||
{
|
||||
$originalId = (int) $original->ID;
|
||||
$copyId = (int) $copy->ID;
|
||||
@@ -226,9 +243,10 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
$updated = get_post($originalId);
|
||||
$updated = $updated instanceof WP_Post ? $updated : $original;
|
||||
$statusNote = sprintf(
|
||||
'; Status bleibt %s, %s.',
|
||||
'; Status bleibt %s, %s, der vorherige Stand liegt als Sicherung ID %d im Papierkorb.',
|
||||
$original->post_status,
|
||||
$trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter'
|
||||
$trashed ? 'Kopie liegt im Papierkorb' : 'Kopie besteht weiter',
|
||||
$backupId
|
||||
);
|
||||
|
||||
$result = ToolResult::ok($summary . $statusNote)
|
||||
@@ -243,20 +261,27 @@ final class PostReplaceWithCopyAbility implements Ability
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw'))
|
||||
->warnings($warnings)
|
||||
->with('backup_id', $backupId)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
|
||||
* $snapshot Output of PostCloner::snapshot().
|
||||
* @param list<string> $parts Parts replace() had (possibly) written.
|
||||
* @throws WriteFailed Always – rolledBack tells whether the original was restored completely.
|
||||
*/
|
||||
private function rollBack(WP_Post $original, array $snapshot, ?int $revisionId, Throwable $cause): never
|
||||
{
|
||||
private function rollBack(
|
||||
WP_Post $original,
|
||||
array $snapshot,
|
||||
array $parts,
|
||||
?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);
|
||||
$this->cloner->restore($original, $snapshot, $parts);
|
||||
} catch (Throwable $e) {
|
||||
$ignored = [];
|
||||
FollowUp::run(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown by PostCloner::replace(). $parts lists the parts of the original replace()
|
||||
* had started to write (post, meta, terms) – only those need to be restored.
|
||||
*/
|
||||
final class ReplaceFailed extends RuntimeException
|
||||
{
|
||||
/** @param list<string> $parts */
|
||||
public function __construct(public readonly array $parts, Throwable $previous)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), 0, $previous);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user