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:
2026-09-25 23:27:07 +02:00
co-authored by Claude Opus 5.5
parent 346619d6da
commit 4fed69cf65
8 changed files with 1215 additions and 0 deletions
@@ -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())
);
}
}
}
}