Files
WP-LIT-MCP/src/Integrations/WordPress/Status/PostCloner.php
T
janniclas.loosenandClaude Opus 5.5 d831cf59ca fix: never publish a missed schedule, refuse trashed posts, fail on meta read errors
- 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
2026-09-26 01:20:21 +02:00

373 lines
14 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\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;
}
}