- PostGuard::editable() refuses trashed entries unless the caller opts in (content-get, revisions-list, post-trash); publish/schedule/unpublish/update/ restore/duplicate/replace now refuse them. - PostGuard::statusStable(): content-update, revision-restore and replace-with-copy refuse a future post whose date has passed (any save would publish it); the content-update rollback and PostCloner row writes re-check. - PostCloner::ownMeta() treats a failed SELECT as an error; replace() reads the copy before its first write. - replace-with-copy needs create_posts (backup clone) and reports backup_id in write_failed/partial_write. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
373 lines
14 KiB
PHP
373 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Status;
|
||
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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,
|
||
];
|
||
|
||
public function __construct(private ?\wpdb $db = null)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
return $this->cloneAsDraft($original, self::COPY_SUFFIX, self::COPY_OF_META, 'Die Kopie');
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
|
||
try {
|
||
$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) {
|
||
$this->discard($original, $backupId, $e, 'Die Sicherung konnte nicht in den Papierkorb gelegt werden.');
|
||
}
|
||
|
||
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. Everything is
|
||
* read before the first write, so a failed read leaves the original untouched.
|
||
*
|
||
* @throws ReplaceFailed Naming the parts that were (possibly) written.
|
||
*/
|
||
public function replace(WP_Post $original, WP_Post $copy): void
|
||
{
|
||
$originalId = (int) $original->ID;
|
||
$parts = [];
|
||
try {
|
||
$meta = $this->ownMeta((int) $copy->ID);
|
||
$terms = $this->termIds($copy);
|
||
$this->assertNoMissedSchedule($originalId);
|
||
$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,
|
||
]);
|
||
$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<?string>>, 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 the given parts of a snapshot back. Tries every part even if one fails.
|
||
*
|
||
* @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, array $parts = self::PARTS): void
|
||
{
|
||
$postId = (int) $post->ID;
|
||
$steps = [
|
||
'post' => fn () => $this->updateRow($postId, $snapshot['post']),
|
||
'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']),
|
||
'terms' => fn () => $this->setTerms($postId, $snapshot['terms']),
|
||
];
|
||
$failures = [];
|
||
foreach (array_intersect_key($steps, array_flip($parts)) 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);
|
||
}
|
||
|
||
/** @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
|
||
*/
|
||
private function updateRow(int $postId, array $columns): void
|
||
{
|
||
$this->assertNoMissedSchedule($postId);
|
||
$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));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row.
|
||
*
|
||
* @throws RuntimeException
|
||
*/
|
||
private function assertNoMissedSchedule(int $postId): void
|
||
{
|
||
$post = get_post($postId);
|
||
if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) {
|
||
throw new RuntimeException(sprintf(
|
||
'Post %d not written: saving it now would publish a missed schedule.',
|
||
$postId
|
||
));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stored meta of a post without SKIP_META: meta_value strings exactly as in the database.
|
||
* A failed query is an error – treating it as "no meta" would wipe meta on replace().
|
||
*
|
||
* @return array<string, list<string|null>>
|
||
* @throws RuntimeException
|
||
*/
|
||
private function ownMeta(int $postId): array
|
||
{
|
||
$db = $this->db();
|
||
$db->last_error = '';
|
||
$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'
|
||
);
|
||
if (! is_array($rows) || (string) $db->last_error !== '') {
|
||
throw new RuntimeException(sprintf(
|
||
'Meta of post %d could not be read: %s',
|
||
$postId,
|
||
(string) $db->last_error !== '' ? (string) $db->last_error : 'no result'
|
||
));
|
||
}
|
||
|
||
$meta = [];
|
||
foreach ($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'];
|
||
}
|
||
}
|
||
|
||
return $meta;
|
||
}
|
||
|
||
/**
|
||
* Makes the post's meta (except SKIP_META) exactly $meta; values are inserted as stored.
|
||
*
|
||
* @param array<string, list<string|null>> $meta
|
||
* @throws RuntimeException
|
||
*/
|
||
private function replaceMeta(int $postId, array $meta): void
|
||
{
|
||
$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');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @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())
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function db(): \wpdb
|
||
{
|
||
if ($this->db === null) {
|
||
global $wpdb;
|
||
$this->db = $wpdb;
|
||
}
|
||
|
||
return $this->db;
|
||
}
|
||
}
|