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
This commit is contained in:
2026-09-26 01:20:21 +02:00
co-authored by Claude Opus 5.5
parent 6a7193424c
commit d831cf59ca
24 changed files with 501 additions and 43 deletions
@@ -5,6 +5,7 @@ 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;
@@ -79,7 +80,8 @@ class PostCloner
/**
* Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy
* into the original. Status, slug, date and author of the original stay.
* 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.
*/
@@ -88,14 +90,15 @@ class PostCloner
$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,
]);
$meta = $this->ownMeta((int) $copy->ID);
$terms = $this->termIds($copy);
$parts[] = 'meta';
$this->replaceMeta($originalId, $meta);
$parts[] = 'terms';
@@ -225,6 +228,7 @@ class PostCloner
*/
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';
@@ -232,14 +236,33 @@ class PostCloner
}
}
/**
* 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",
@@ -247,9 +270,16 @@ class PostCloner
),
'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 ((array) $rows as $row) {
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'];